- 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>
148 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|