feat(api): add HTTP server with full REST API

Chi router with all entity CRUD endpoints, promote/demote/use
actions, tag listing. CORS middleware for --dev mode. Graceful
shutdown on SIGINT/SIGTERM. 22 API integration tests via httptest.
This commit is contained in:
2026-05-14 11:30:47 -04:00
parent c3cc9464b9
commit 6de174e474
6 changed files with 987 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/lerko/nib/internal/db"
)
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
type EntityResponse struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Body string `json:"body"`
Glyph string `json:"glyph"`
TimeAnchor *string `json:"time_anchor"`
CompletedAt *string `json:"completed_at"`
Pinned bool `json:"pinned"`
DeletedAt *string `json:"deleted_at"`
Tags []string `json:"tags"`
CardType *string `json:"card_type"`
CardData *string `json:"card_data"`
UseCount int `json:"use_count"`
LastUsedAt *string `json:"last_used_at"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, ErrorResponse{Error: code, Message: message})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
return false
}
return true
}
func entityToResponse(e *db.Entity) EntityResponse {
resp := EntityResponse{
ID: e.ID,
CreatedAt: e.CreatedAt.Format(time.RFC3339),
ModifiedAt: e.ModifiedAt.Format(time.RFC3339),
Body: e.Body,
Glyph: string(e.Glyph),
Pinned: e.Pinned,
Tags: e.Tags,
UseCount: e.UseCount,
}
if resp.Tags == nil {
resp.Tags = []string{}
}
resp.TimeAnchor = e.TimeAnchor
resp.CompletedAt = formatTimeRespPtr(e.CompletedAt)
resp.DeletedAt = formatTimeRespPtr(e.DeletedAt)
resp.LastUsedAt = formatTimeRespPtr(e.LastUsedAt)
if e.CardType != nil {
s := string(*e.CardType)
resp.CardType = &s
}
resp.CardData = e.CardData
return resp
}
func formatTimeRespPtr(t *time.Time) *string {
if t == nil {
return nil
}
s := t.Format(time.RFC3339)
return &s
}