From 6de174e4746a7c43240e1a0a2cf42a8109c56b48 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 11:30:47 -0400 Subject: [PATCH] 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. --- cmd/serve.go | 81 ++++++++ internal/api/api_test.go | 418 +++++++++++++++++++++++++++++++++++++++ internal/api/entities.go | 319 ++++++++++++++++++++++++++++++ internal/api/helpers.go | 83 ++++++++ internal/api/router.go | 58 ++++++ internal/api/tags.go | 28 +++ 6 files changed, 987 insertions(+) create mode 100644 cmd/serve.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/entities.go create mode 100644 internal/api/helpers.go create mode 100644 internal/api/router.go create mode 100644 internal/api/tags.go diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..591893d --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/lerko/nib/internal/api" + "github.com/spf13/cobra" +) + +var ( + servePort int + serveDev bool +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "start the HTTP server", + RunE: runServe, +} + +func init() { + serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444)") + serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development") + rootCmd.AddCommand(serveCmd) +} + +func runServe(_ *cobra.Command, _ []string) error { + port := servePort + if port == 0 { + if envPort := os.Getenv("NIB_PORT"); envPort != "" { + p, err := strconv.Atoi(envPort) + if err != nil { + return fmt.Errorf("invalid NIB_PORT: %w", err) + } + port = p + } else { + port = 4444 + } + } + + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + router := api.NewRouter(store, serveDev) + + addr := fmt.Sprintf(":%d", port) + srv := &http.Server{ + Addr: addr, + Handler: router, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + fmt.Printf("nib serving on %s\n", addr) + if serveDev { + fmt.Println(" CORS enabled (dev mode)") + } + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "server error: %v\n", err) + } + }() + + <-ctx.Done() + fmt.Println("\nshutting down...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..6b720e0 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,418 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/lerko/nib/internal/db" +) + +func testServer(t *testing.T) (*httptest.Server, *db.Store) { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + store, err := db.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { store.Close() }) + router := NewRouter(store, false) + srv := httptest.NewServer(router) + t.Cleanup(srv.Close) + return srv, store +} + +func postJSON(srv *httptest.Server, path string, body any) *http.Response { + b, _ := json.Marshal(body) + resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) + return resp +} + +func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse { + t.Helper() + resp := postJSON(srv, "/api/entities", map[string]any{ + "body": body, + "tags": tags, + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create failed: %d", resp.StatusCode) + } + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + resp.Body.Close() + return e +} + +func TestCreateEntity_Note(t *testing.T) { + srv, _ := testServer(t) + + resp := postJSON(srv, "/api/entities", map[string]any{ + "body": "test note", + "tags": []string{"demo"}, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + + if e.Body != "test note" { + t.Errorf("body: %q", e.Body) + } + if e.Glyph != "note" { + t.Errorf("glyph: %q", e.Glyph) + } + if len(e.Tags) != 1 || e.Tags[0] != "demo" { + t.Errorf("tags: %v", e.Tags) + } +} + +func TestCreateEntity_MissingBody(t *testing.T) { + srv, _ := testServer(t) + + resp := postJSON(srv, "/api/entities", map[string]any{}) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + var errResp ErrorResponse + json.NewDecoder(resp.Body).Decode(&errResp) + if errResp.Error != "invalid_input" { + t.Errorf("error code: %q", errResp.Error) + } +} + +func TestCreateEntity_InvalidGlyph(t *testing.T) { + srv, _ := testServer(t) + + resp := postJSON(srv, "/api/entities", map[string]any{ + "body": "test", + "glyph": "invalid", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestGetEntity_Success(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "test", nil) + + resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.ID != created.ID { + t.Errorf("id mismatch: %q != %q", e.ID, created.ID) + } +} + +func TestGetEntity_NotFound(t *testing.T) { + srv, _ := testServer(t) + + resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestListEntities_Default(t *testing.T) { + srv, _ := testServer(t) + createTestEntity(t, srv, "one", nil) + createTestEntity(t, srv, "two", nil) + + resp, _ := http.Get(srv.URL + "/api/entities") + defer resp.Body.Close() + + var entities []EntityResponse + json.NewDecoder(resp.Body).Decode(&entities) + if len(entities) != 2 { + t.Fatalf("expected 2, got %d", len(entities)) + } +} + +func TestListEntities_FilterTag(t *testing.T) { + srv, _ := testServer(t) + createTestEntity(t, srv, "a", []string{"ops"}) + createTestEntity(t, srv, "b", []string{"home"}) + + resp, _ := http.Get(srv.URL + "/api/entities?tag=ops") + defer resp.Body.Close() + + var entities []EntityResponse + json.NewDecoder(resp.Body).Decode(&entities) + if len(entities) != 1 { + t.Fatalf("expected 1, got %d", len(entities)) + } +} + +func TestListEntities_CardsOnly(t *testing.T) { + srv, _ := testServer(t) + createTestEntity(t, srv, "fluid", nil) + + resp := postJSON(srv, "/api/entities", map[string]any{ + "body": "card", + "card_type": "snippet", + }) + resp.Body.Close() + + resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true") + defer resp.Body.Close() + + var entities []EntityResponse + json.NewDecoder(resp.Body).Decode(&entities) + if len(entities) != 1 { + t.Fatalf("expected 1 card, got %d", len(entities)) + } +} + +func TestListEntities_Pagination(t *testing.T) { + srv, _ := testServer(t) + for i := 0; i < 5; i++ { + createTestEntity(t, srv, "note", nil) + } + + resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0") + var page1 []EntityResponse + json.NewDecoder(resp.Body).Decode(&page1) + resp.Body.Close() + + resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2") + var page2 []EntityResponse + json.NewDecoder(resp.Body).Decode(&page2) + resp.Body.Close() + + if len(page1) != 2 || len(page2) != 2 { + t.Fatalf("pages: %d, %d", len(page1), len(page2)) + } + if page1[0].ID == page2[0].ID { + t.Error("pages overlap") + } +} + +func TestUpdateEntity_Body(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "old", nil) + + req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader( + mustJSON(map[string]any{"body": "new"}))) + req.Header.Set("Content-Type", "application/json") + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.Body != "new" { + t.Errorf("body: %q", e.Body) + } +} + +func TestDeleteEntity_SoftThenHard(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "doomed", nil) + + // Soft delete + req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil) + resp, _ := http.DefaultClient.Do(req) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("soft delete: expected 204, got %d", resp.StatusCode) + } + + // Hard delete + resp, _ = http.DefaultClient.Do(req) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("hard delete: expected 204, got %d", resp.StatusCode) + } + + // Gone + resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode) + } +} + +func TestPromoteEntity_Success(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "trick", nil) + + resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + "card_type": "snippet", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.CardType == nil || *e.CardType != "snippet" { + t.Errorf("card_type: %v", e.CardType) + } +} + +func TestPromoteEntity_AlreadyPromoted(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "trick", nil) + + postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + "card_type": "snippet", + }).Body.Close() + + resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + "card_type": "template", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + var errResp ErrorResponse + json.NewDecoder(resp.Body).Decode(&errResp) + if errResp.Error != "invalid_promote" { + t.Errorf("error: %q", errResp.Error) + } +} + +func TestPromoteEntity_InvalidType(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "trick", nil) + + resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + "card_type": "bogus", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestDemoteEntity_Success(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "trick", nil) + + postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{ + "card_type": "snippet", + }).Body.Close() + + resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.CardType != nil { + t.Errorf("card_type should be nil: %v", e.CardType) + } +} + +func TestDemoteEntity_AlreadyFluid(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "trick", nil) + + resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestUseEntity_Success(t *testing.T) { + srv, _ := testServer(t) + created := createTestEntity(t, srv, "trick", nil) + + resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var e EntityResponse + json.NewDecoder(resp.Body).Decode(&e) + if e.UseCount != 1 { + t.Errorf("use_count: %d", e.UseCount) + } +} + +func TestListTags_WithCounts(t *testing.T) { + srv, _ := testServer(t) + createTestEntity(t, srv, "a", []string{"ops"}) + createTestEntity(t, srv, "b", []string{"ops", "nginx"}) + + resp, _ := http.Get(srv.URL + "/api/tags") + defer resp.Body.Close() + + var tags []TagResponse + json.NewDecoder(resp.Body).Decode(&tags) + if len(tags) != 2 { + t.Fatalf("expected 2 tags, got %d", len(tags)) + } +} + +func TestCORS_DevMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + store, _ := db.Open(path) + defer store.Close() + router := NewRouter(store, true) + srv := httptest.NewServer(router) + defer srv.Close() + + req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.Header.Get("Access-Control-Allow-Origin") != "*" { + t.Error("CORS header missing in dev mode") + } + if resp.StatusCode != http.StatusNoContent { + t.Errorf("OPTIONS: expected 204, got %d", resp.StatusCode) + } +} + +func TestCORS_ProdMode(t *testing.T) { + srv, _ := testServer(t) // devMode=false + + req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + + if resp.Header.Get("Access-Control-Allow-Origin") != "" { + t.Error("CORS header should not be set in prod mode") + } +} + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + return b +} diff --git a/internal/api/entities.go b/internal/api/entities.go new file mode 100644 index 0000000..19d0d41 --- /dev/null +++ b/internal/api/entities.go @@ -0,0 +1,319 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/lerko/nib/internal/db" +) + +type CreateEntityRequest struct { + Body string `json:"body"` + Glyph *string `json:"glyph"` + TimeAnchor *string `json:"time_anchor"` + Tags []string `json:"tags"` + CardType *string `json:"card_type"` + CardData *string `json:"card_data"` +} + +type UpdateEntityRequest struct { + Body *string `json:"body"` + Glyph *string `json:"glyph"` + TimeAnchor *string `json:"time_anchor"` + Tags *[]string `json:"tags"` + Pinned *bool `json:"pinned"` + CardType *string `json:"card_type"` + CardData *string `json:"card_data"` +} + +type PromoteRequest struct { + CardType string `json:"card_type"` + CardData *string `json:"card_data"` +} + +func listEntities(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p := db.DefaultListParams() + + if tag := r.URL.Query().Get("tag"); tag != "" { + p.Tag = &tag + } + if date := r.URL.Query().Get("date"); date != "" { + if _, err := time.Parse("2006-01-02", date); err != nil { + writeError(w, http.StatusBadRequest, "invalid_input", "bad date format, use YYYY-MM-DD") + return + } + p.Date = &date + } + if r.URL.Query().Get("cards_only") == "true" { + p.CardsOnly = true + } + if r.URL.Query().Get("include_deleted") == "true" { + p.IncludeDeleted = true + } + if sort := r.URL.Query().Get("sort"); sort != "" { + if sort != "created" && sort != "use_count" { + writeError(w, http.StatusBadRequest, "invalid_input", "sort must be 'created' or 'use_count'") + return + } + p.Sort = sort + } + if order := r.URL.Query().Get("order"); order != "" { + if order != "asc" && order != "desc" { + writeError(w, http.StatusBadRequest, "invalid_input", "order must be 'asc' or 'desc'") + return + } + p.Order = order + } + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 { + writeError(w, http.StatusBadRequest, "invalid_input", "limit must be a positive integer") + return + } + p.Limit = limit + } + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + writeError(w, http.StatusBadRequest, "invalid_input", "offset must be a non-negative integer") + return + } + p.Offset = offset + } + + entities, err := store.List(p) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + resp := make([]EntityResponse, len(entities)) + for i, e := range entities { + resp[i] = entityToResponse(e) + } + writeJSON(w, http.StatusOK, resp) + } +} + +func createEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req CreateEntityRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Body == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "body is required") + return + } + + glyph := db.GlyphNote + if req.Glyph != nil { + if !db.ValidGlyph(*req.Glyph) { + writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value") + return + } + glyph = db.Glyph(*req.Glyph) + } + + e := &db.Entity{ + Body: req.Body, + Glyph: glyph, + TimeAnchor: req.TimeAnchor, + Tags: req.Tags, + } + + if req.CardType != nil { + if !db.ValidCardType(*req.CardType) { + writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value") + return + } + ct := db.CardType(*req.CardType) + e.CardType = &ct + e.CardData = req.CardData + } + + if err := store.Create(e); err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + writeJSON(w, http.StatusCreated, entityToResponse(e)) + } +} + +func getEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + e, err := store.Get(id) + if err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, entityToResponse(e)) + } +} + +func updateEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req UpdateEntityRequest + if !decodeJSON(w, r, &req) { + return + } + + u := &db.EntityUpdate{} + u.Body = req.Body + u.Tags = req.Tags + u.Pinned = req.Pinned + u.CardData = req.CardData + + if req.Glyph != nil { + if !db.ValidGlyph(*req.Glyph) { + writeError(w, http.StatusBadRequest, "invalid_input", "invalid glyph value") + return + } + g := db.Glyph(*req.Glyph) + u.Glyph = &g + } + if req.TimeAnchor != nil { + u.TimeAnchor = req.TimeAnchor + } + if req.CardType != nil { + if !db.ValidCardType(*req.CardType) { + writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value") + return + } + ct := db.CardType(*req.CardType) + u.CardType = &ct + } + + if err := store.Update(id, u); err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + e, err := store.Get(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, entityToResponse(e)) + } +} + +func deleteEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + _, err := store.SoftDelete(id) + if err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func promoteEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req PromoteRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.CardType == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "card_type is required") + return + } + if !db.ValidCardType(req.CardType) { + writeError(w, http.StatusBadRequest, "invalid_type", "invalid card_type value") + return + } + + if err := store.Promote(id, db.CardType(req.CardType), req.CardData); err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) + return + } + if err == db.ErrAlreadyPromoted { + writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + e, err := store.Get(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, entityToResponse(e)) + } +} + +func demoteEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if err := store.Demote(id); err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) + return + } + if err == db.ErrAlreadyFluid { + writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + e, err := store.Get(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, entityToResponse(e)) + } +} + +func useEntity(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if err := store.IncrementUse(id); err != nil { + if err == db.ErrNotFound { + writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + e, err := store.Get(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, entityToResponse(e)) + } +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go new file mode 100644 index 0000000..d9723ad --- /dev/null +++ b/internal/api/helpers.go @@ -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 +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..3507e0d --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/lerko/nib/internal/db" +) + +func NewRouter(store *db.Store, devMode bool) chi.Router { + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + if devMode { + r.Use(corsMiddleware) + } + + r.Route("/api", func(r chi.Router) { + r.Use(jsonContentType) + + r.Get("/entities", listEntities(store)) + r.Post("/entities", createEntity(store)) + r.Get("/entities/{id}", getEntity(store)) + r.Put("/entities/{id}", updateEntity(store)) + r.Delete("/entities/{id}", deleteEntity(store)) + r.Post("/entities/{id}/promote", promoteEntity(store)) + r.Post("/entities/{id}/demote", demoteEntity(store)) + r.Post("/entities/{id}/use", useEntity(store)) + r.Get("/tags", listTags(store)) + }) + + return r +} + +func jsonContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/tags.go b/internal/api/tags.go new file mode 100644 index 0000000..9d26ca1 --- /dev/null +++ b/internal/api/tags.go @@ -0,0 +1,28 @@ +package api + +import ( + "net/http" + + "github.com/lerko/nib/internal/db" +) + +type TagResponse struct { + Tag string `json:"tag"` + Count int `json:"count"` +} + +func listTags(store *db.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tags, err := store.ListTags() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + resp := make([]TagResponse, len(tags)) + for i, tc := range tags { + resp[i] = TagResponse{Tag: tc.Tag, Count: tc.Count} + } + writeJSON(w, http.StatusOK, resp) + } +}