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>
This commit is contained in:
147
internal/httpapi/server.go
Normal file
147
internal/httpapi/server.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user