feat: add JSON REST API under /api/v1/

Phase 6 — JSON API:
- POST /api/v1/auth/login — returns session token
- POST /api/v1/auth/logout
- GET/POST /api/v1/faves — list own faves (paginated), create fave
- GET/PUT/DELETE /api/v1/faves/{id} — get, update, delete fave
- GET /api/v1/tags?q= — search tags
- GET /api/v1/users/{username} — public profile
- GET /api/v1/users/{username}/faves — public faves (paginated)
- GET /api/v1/export/json — export own faves
- POST /api/v1/import — import faves from JSON

All endpoints return JSON. Auth via session cookie (same as web UI).
Privacy-aware: private faves hidden from non-owners.
Respects profile visibility settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-29 16:13:23 +02:00
commit fe4c751289
2 changed files with 470 additions and 0 deletions

View file

@ -16,6 +16,7 @@ import (
"kode.naiv.no/olemd/favoritter/internal/config"
"kode.naiv.no/olemd/favoritter/internal/database"
"kode.naiv.no/olemd/favoritter/internal/handler"
"kode.naiv.no/olemd/favoritter/internal/handler/api"
"kode.naiv.no/olemd/favoritter/internal/middleware"
"kode.naiv.no/olemd/favoritter/internal/render"
"kode.naiv.no/olemd/favoritter/internal/store"
@ -104,8 +105,18 @@ func main() {
Renderer: renderer,
})
// Register JSON API routes on the same mux.
apiHandler := api.New(api.Deps{
Config: cfg,
Users: users,
Sessions: sessions,
Faves: faves,
Tags: tags,
})
// Build the middleware chain.
mux := h.Routes()
apiHandler.Routes(mux)
chain := middleware.Chain(
mux,
middleware.Recovery,

459
internal/handler/api/api.go Normal file
View file

@ -0,0 +1,459 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package api provides JSON REST API handlers under /api/v1/.
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"kode.naiv.no/olemd/favoritter/internal/config"
"kode.naiv.no/olemd/favoritter/internal/middleware"
"kode.naiv.no/olemd/favoritter/internal/model"
"kode.naiv.no/olemd/favoritter/internal/store"
)
// Deps bundles dependencies for API handlers.
type Deps struct {
Config *config.Config
Users *store.UserStore
Sessions *store.SessionStore
Faves *store.FaveStore
Tags *store.TagStore
}
// Handler holds API handler methods.
type Handler struct {
deps Deps
}
// New creates a new API handler.
func New(deps Deps) *Handler {
return &Handler{deps: deps}
}
// Routes registers all API routes on the given mux under /api/v1/.
func (h *Handler) Routes(mux *http.ServeMux) {
requireLogin := middleware.RequireLogin("")
// Auth.
mux.HandleFunc("POST /api/v1/auth/login", h.handleLogin)
mux.Handle("POST /api/v1/auth/logout", requireLogin(http.HandlerFunc(h.handleLogout)))
// Faves.
mux.Handle("GET /api/v1/faves", requireLogin(http.HandlerFunc(h.handleListFaves)))
mux.Handle("POST /api/v1/faves", requireLogin(http.HandlerFunc(h.handleCreateFave)))
mux.HandleFunc("GET /api/v1/faves/{id}", h.handleGetFave)
mux.Handle("PUT /api/v1/faves/{id}", requireLogin(http.HandlerFunc(h.handleUpdateFave)))
mux.Handle("DELETE /api/v1/faves/{id}", requireLogin(http.HandlerFunc(h.handleDeleteFave)))
// Tags.
mux.HandleFunc("GET /api/v1/tags", h.handleSearchTags)
// Users.
mux.HandleFunc("GET /api/v1/users/{username}", h.handleGetUser)
mux.HandleFunc("GET /api/v1/users/{username}/faves", h.handleGetUserFaves)
// Export/Import.
mux.Handle("GET /api/v1/export/json", requireLogin(http.HandlerFunc(h.handleExport)))
mux.Handle("POST /api/v1/import", requireLogin(http.HandlerFunc(h.handleImport)))
}
// --- Auth ---
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.deps.Users.Authenticate(req.Username, req.Password)
if err != nil {
jsonError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := h.deps.Sessions.Create(user.ID)
if err != nil {
slog.Error("api: session create error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
jsonOK(w, map[string]any{
"token": token,
"user": userJSON(user),
})
}
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(middleware.SessionCookieName)
if err == nil {
h.deps.Sessions.Delete(cookie.Value)
}
middleware.ClearSessionCookie(w)
jsonOK(w, map[string]string{"status": "ok"})
}
// --- Faves ---
func (h *Handler) handleListFaves(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
page := queryInt(r, "page", 1)
limit := queryInt(r, "limit", 24)
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
faves, total, err := h.deps.Faves.ListByUser(user.ID, limit, offset)
if err != nil {
slog.Error("api: list faves error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
h.deps.Faves.LoadTags(faves)
jsonOK(w, map[string]any{
"faves": favesJSON(faves),
"total": total,
"page": page,
})
}
func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
var req struct {
Description string `json:"description"`
URL string `json:"url"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Description == "" {
jsonError(w, "Description is required", http.StatusBadRequest)
return
}
if req.Privacy != "public" && req.Privacy != "private" {
req.Privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, req.Description, req.URL, "", req.Privacy)
if err != nil {
slog.Error("api: create fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
if len(req.Tags) > 0 {
h.deps.Tags.SetFaveTags(fave.ID, req.Tags)
}
tags, _ := h.deps.Tags.ForFave(fave.ID)
fave.Tags = tags
w.WriteHeader(http.StatusCreated)
jsonOK(w, faveJSON(fave))
}
func (h *Handler) handleGetFave(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonError(w, "Invalid ID", http.StatusBadRequest)
return
}
fave, err := h.deps.Faves.GetByID(id)
if err != nil {
if errors.Is(err, store.ErrFaveNotFound) {
jsonError(w, "Not found", http.StatusNotFound)
return
}
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
user := middleware.UserFromContext(r.Context())
if fave.Privacy == "private" && (user == nil || user.ID != fave.UserID) {
jsonError(w, "Not found", http.StatusNotFound)
return
}
tags, _ := h.deps.Tags.ForFave(fave.ID)
fave.Tags = tags
jsonOK(w, faveJSON(fave))
}
func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonError(w, "Invalid ID", http.StatusBadRequest)
return
}
fave, err := h.deps.Faves.GetByID(id)
if err != nil {
if errors.Is(err, store.ErrFaveNotFound) {
jsonError(w, "Not found", http.StatusNotFound)
return
}
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
if user.ID != fave.UserID {
jsonError(w, "Forbidden", http.StatusForbidden)
return
}
var req struct {
Description string `json:"description"`
URL string `json:"url"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Description == "" {
req.Description = fave.Description
}
if req.Privacy != "public" && req.Privacy != "private" {
req.Privacy = fave.Privacy
}
h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy)
if req.Tags != nil {
h.deps.Tags.SetFaveTags(id, req.Tags)
}
updated, _ := h.deps.Faves.GetByID(id)
tags, _ := h.deps.Tags.ForFave(id)
updated.Tags = tags
jsonOK(w, faveJSON(updated))
}
func (h *Handler) handleDeleteFave(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonError(w, "Invalid ID", http.StatusBadRequest)
return
}
fave, err := h.deps.Faves.GetByID(id)
if err != nil {
if errors.Is(err, store.ErrFaveNotFound) {
jsonError(w, "Not found", http.StatusNotFound)
return
}
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
if user.ID != fave.UserID {
jsonError(w, "Forbidden", http.StatusForbidden)
return
}
h.deps.Faves.Delete(id)
w.WriteHeader(http.StatusNoContent)
}
// --- Tags ---
func (h *Handler) handleSearchTags(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limit := queryInt(r, "limit", 20)
if limit > 100 {
limit = 100
}
tags, err := h.deps.Tags.Search(q, limit)
if err != nil {
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
tagNames := make([]string, len(tags))
for i, t := range tags {
tagNames[i] = t.Name
}
jsonOK(w, map[string]any{"tags": tagNames})
}
// --- Users ---
func (h *Handler) handleGetUser(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
user, err := h.deps.Users.GetByUsername(username)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
jsonError(w, "Not found", http.StatusNotFound)
return
}
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
if user.Disabled {
jsonError(w, "Not found", http.StatusNotFound)
return
}
jsonOK(w, userJSON(user))
}
func (h *Handler) handleGetUserFaves(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
user, err := h.deps.Users.GetByUsername(username)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
jsonError(w, "Not found", http.StatusNotFound)
return
}
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
page := queryInt(r, "page", 1)
limit := queryInt(r, "limit", 24)
offset := (page - 1) * limit
faves, total, err := h.deps.Faves.ListPublicByUser(user.ID, limit, offset)
if err != nil {
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
h.deps.Faves.LoadTags(faves)
jsonOK(w, map[string]any{
"faves": favesJSON(faves),
"total": total,
"page": page,
})
}
// --- Export/Import ---
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
faves, _, err := h.deps.Faves.ListByUser(user.ID, 10000, 0)
if err != nil {
jsonError(w, "Internal error", http.StatusInternalServerError)
return
}
h.deps.Faves.LoadTags(faves)
jsonOK(w, favesJSON(faves))
}
func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
var faves []struct {
Description string `json:"description"`
URL string `json:"url"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&faves); err != nil {
jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
imported := 0
for _, f := range faves {
if f.Description == "" {
continue
}
privacy := f.Privacy
if privacy != "public" && privacy != "private" {
privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, f.Description, f.URL, "", privacy)
if err != nil {
continue
}
if len(f.Tags) > 0 {
h.deps.Tags.SetFaveTags(fave.ID, f.Tags)
}
imported++
}
jsonOK(w, map[string]any{
"imported": imported,
"total": len(faves),
})
}
// --- JSON helpers ---
func jsonOK(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func userJSON(u *model.User) map[string]any {
result := map[string]any{
"username": u.Username,
"display_name": u.DisplayNameOrUsername(),
}
if u.ProfileVisibility == "public" {
result["bio"] = u.Bio
result["avatar_path"] = u.AvatarPath
result["created_at"] = u.CreatedAt
}
return result
}
func faveJSON(f *model.Fave) map[string]any {
tags := make([]string, len(f.Tags))
for i, t := range f.Tags {
tags[i] = t.Name
}
return map[string]any{
"id": f.ID,
"description": f.Description,
"url": f.URL,
"image_path": f.ImagePath,
"privacy": f.Privacy,
"tags": tags,
"username": f.Username,
"created_at": f.CreatedAt,
"updated_at": f.UpdatedAt,
}
}
func favesJSON(faves []*model.Fave) []map[string]any {
result := make([]map[string]any, len(faves))
for i, f := range faves {
result[i] = faveJSON(f)
}
return result
}
func queryInt(r *http.Request, key string, fallback int) int {
v := r.URL.Query().Get(key)
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return fallback
}
return n
}