diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 00000000..ab77d087 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,49 @@ +// Package api implements Minstrel's native JSON surface under /api. It is +// consumed by the built-in web SPA and (eventually) the Flutter client. +// Subsonic-compatible endpoints under /rest are intentionally separate — +// see internal/subsonic — and the two packages must not depend on each other. +package api + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +// Mount attaches /api/* handlers to r. Public endpoints (login) are outside +// RequireUser; everything else is gated by the middleware. +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) { + h := &handlers{pool: pool, logger: logger} + + r.Route("/api", func(api chi.Router) { + api.Post("/auth/login", h.handleLogin) + api.Group(func(authed chi.Router) { + authed.Use(auth.RequireUser(pool)) + authed.Post("/auth/logout", h.handleLogout) + authed.Get("/me", h.handleGetMe) + }) + }) +} + +type handlers struct { + pool *pgxpool.Pool + logger *slog.Logger +} + +// Stub handlers so Mount() compiles. Real implementations in later tasks +// replace these in place (Task 6 login, Task 7 logout, Task 8 me). +func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotImplemented, "not_implemented", "login not wired") +} + +func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotImplemented, "not_implemented", "logout not wired") +} + +func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotImplemented, "not_implemented", "me not wired") +} diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 00000000..f2df9ec0 --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,25 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// errorBody is the JSON envelope for failures on /api/*. Kept separate from +// the Subsonic envelope so native clients don't have to parse two shapes. +type errorBody struct { + Error errorPayload `json:"error"` +} + +type errorPayload struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// writeErr is the canonical way to emit a failure. Code is a stable +// short-string clients can switch on; message is human-readable. +func writeErr(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}}) +} diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go new file mode 100644 index 00000000..8c7a4de1 --- /dev/null +++ b/internal/api/errors_test.go @@ -0,0 +1,32 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWriteErr_EmitsJSONEnvelope(t *testing.T) { + w := httptest.NewRecorder() + writeErr(w, http.StatusBadRequest, "bad_request", "field x required") + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if got := w.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("content-type = %q, want application/json", got) + } + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v\nbody=%s", err, w.Body.String()) + } + if body.Error.Code != "bad_request" || body.Error.Message != "field x required" { + t.Errorf("body = %+v", body) + } +}