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:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user