Files
pansy/internal/api/api.go
T
steveandClaude Opus 4.8 3dd935fb19
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s
Add garden objects backend: polymorphic CRUD + /full (#10)
Garden objects (beds, bags, containers, in-ground, trees, paths,
structures) in one polymorphic table, following the #7 service
conventions.

- store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden
  (z_index order), version-guarded Update (RETURNING, returns current row
  on conflict), Delete (plantings cascade via FK).
- store/plantings.go + plants.go: the read side /full needs now —
  ListActivePlantingsForGarden (removed_at IS NULL) and
  ListReferencedPlants (distinct plants used by active plantings). Both
  return empty until #14 adds plantings; #12/#14 extend these files.
- service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all
  (ctx, actorID, ...) through requireGardenRole(editor for mutations,
  viewer for /full). finalizeObject validates kind + shape (rect/circle;
  polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose
  bbox-overlaps-garden placement (partial overhang OK), hex color, valid
  JSON props, name/notes caps; normalizes rotation to [0,360). Plantable
  defaults by kind, overridable.
- api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id,
  GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any
  JSON value stored as text; PATCH is partial + required version, 409
  returns the current row.

Tests: service (defaults, plantable-by-kind, rotation normalize,
validation rejects incl. off-field/polygon/bad-color/bad-props, partial
patch + version conflict, cross-user ErrNotFound, delete, /full shape) and
api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400,
props round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:30:30 -04:00

100 lines
3.8 KiB
Go

// Package api wires pansy's HTTP surface: a gin engine with structured logging
// and panic recovery, the versioned JSON API under /api/v1, and (via spa.go) the
// embedded single-page-app fallback. Handlers stay thin — decode, call the
// service layer, encode — so all business logic and permission checks live in
// internal/service (added by later issues).
package api
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// handlers carries the dependencies shared by every HTTP handler. Handlers stay
// thin: decode the request, call a service method, encode the result.
type handlers struct {
cfg *config.Config
svc *service.Service
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
}
// New builds the gin engine with the standard middleware stack and registers the
// API routes against the given service. The embedded SPA fallback is registered
// separately by the caller via RegisterSPA (see spa.go) so the API can be built
// and tested without a web build present.
func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(sloggin.New(slog.Default()), gin.Recovery())
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
// Do not leave gin's trust-everyone default active on a parse failure —
// that would let any client spoof X-Forwarded-For. Fall back to trusting
// no proxies, which is also the behavior when none are configured.
slog.Error("api: invalid trusted proxies, trusting none", "error", err)
_ = r.SetTrustedProxies(nil)
}
h := &handlers{cfg: cfg, svc: svc}
v1 := r.Group("/api/v1")
// CSRF defense for every state-changing API call (no-op unless PANSY_BASE_URL
// is set; see csrfGuard).
v1.Use(h.csrfGuard())
v1.GET("/healthz", healthz)
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
// /me is the one that needs a session. Feature routers in later issues attach
// h.requireAuth() to their own protected groups.
auth := v1.Group("/auth")
auth.POST("/register", h.register)
auth.POST("/login", h.login)
auth.POST("/logout", h.logout)
auth.GET("/providers", h.providers)
auth.GET("/me", h.requireAuth(), h.me)
// OIDC routes exist only when OIDC can actually be offered, so an unconfigured
// instance 404s them (matching what /auth/providers advertises). Provider
// discovery is lazy (first request), so a briefly-unreachable IdP doesn't stop
// the server — or local auth — from starting.
switch {
case cfg.OIDCReady():
h.oidc = newOIDCClient(cfg)
auth.GET("/oidc/login", h.oidcLogin)
auth.GET("/oidc/callback", h.oidcCallback)
case cfg.OIDC.Enabled():
slog.Warn("api: OIDC is configured but PANSY_BASE_URL is unset; OIDC disabled (an absolute redirect URI is required)")
}
// Feature resources sit behind requireAuth, which resolves the session cookie
// to the actor the service layer's permission checks key off.
gardens := v1.Group("/gardens", h.requireAuth())
gardens.GET("", h.listGardens)
gardens.POST("", h.createGarden)
gardens.GET("/:id", h.getGarden)
gardens.PATCH("/:id", h.updateGarden)
gardens.DELETE("/:id", h.deleteGarden)
gardens.GET("/:id/full", h.gardenFull) // one-shot editor load
gardens.POST("/:id/objects", h.createObject)
// Objects are addressed by their own id; the service resolves the owning
// garden for the permission check.
objects := v1.Group("/objects", h.requireAuth())
objects.PATCH("/:id", h.updateObject)
objects.DELETE("/:id", h.deleteObject)
return r
}
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
func healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}