Files
oikos/internal/httpapi/server.go
dtoro f2fe812cda phase 2 (part 1): OpenAPI-generated API server, first 9 endpoints
- api/openapi.yaml converted 3.1 → 3.0.3 (oapi-codegen/kin-openapi
  supports 3.0; nullable syntax + example keywords), still redocly-clean
- oapi-codegen (v2.4.1, strict server + chi) generates
  internal/httpapi/gen from the spec; `make generate` wired
- internal/httpapi: chi router, /healthz (unauthenticated, SG18),
  RFC 9457 problem+json mapping from domain sentinels (SG11), 5xx detail
  logged server-side only, request logging with request IDs, graceful
  shutdown (SG4), interim static bearer auth (constant-time; dev-open
  when no token; OIDC JWT still to come in Phase 2)
- Implemented: listEntities (type filter walks the hierarchy, keyset
  pagination), getEntity (UUID or slug, ETag), getEntityRelations,
  getBlastRadius, getGraph (nodes+edges for UIs), getOntology,
  listSignals, getFleetHealth, exportSeeds. Remaining 38 ops return 501
  problem+json stubs (compiler-enforced interface completeness)
- `oikos api` role live: migrate-on-start, serves :8090
- 15 API integration tests (auth, pagination, hierarchy filter, ETag,
  404/501 problem shapes, graph, export)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:25:19 +02:00

148 lines
4.5 KiB
Go

// Package httpapi implements the Oikos REST API. The contract is
// api/openapi.yaml (contract-first, ADR-0004); handlers implement the
// oapi-codegen strict-server interface in gen/. Errors map to RFC 9457
// problem+json via domain sentinels.
package httpapi
import (
"context"
"crypto/subtle"
"log/slog"
"net/http"
"strings"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Server implements gen.StrictServerInterface over the DB layer.
type Server struct {
pool *db.Pool
cfg config.Config
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
func NewHandler(pool *db.Pool, cfg config.Config) http.Handler {
s := &Server{pool: pool, cfg: cfg}
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(requestLogger)
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
defer cancel()
if err := pool.Ping(ctx); err != nil {
writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
},
ResponseErrorHandlerFunc: writeProblemFromErr,
})
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1",
BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{bearerAuth(cfg)},
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
},
})
return r
}
// bearerAuth is the interim Phase 2 auth: a static bearer token
// (OIKOS_API_TOKEN / OIKOS_MCP_BEARER_TOKEN from Infisical in prod). In
// dev mode with no token configured, requests pass as the operator.
// Authentik OIDC JWT validation (operator/viewer scopes) lands later in
// Phase 2 — tracked in the plan's AuthN/AuthZ table.
func bearerAuth(cfg config.Config) func(http.Handler) http.Handler {
tokens := [][]byte{}
if cfg.APIToken != "" {
tokens = append(tokens, []byte(cfg.APIToken))
}
if cfg.MCPBearerToken != "" {
tokens = append(tokens, []byte(cfg.MCPBearerToken))
}
devOpen := cfg.APIEnv == "dev" && len(tokens) == 0
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devOpen {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ")
if ok {
for _, t := range tokens {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
next.ServeHTTP(w, r)
return
}
}
}
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing or invalid bearer token")
})
}
}
// requestLogger logs one line per request with method, path, status,
// duration, and the chi request id.
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
slog.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(pool, cfg),
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("api listening", "addr", cfg.APIListen)
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
slog.Info("api shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}