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:
2026-07-07 08:25:19 +02:00
parent 1b04683639
commit f2fe812cda
13 changed files with 10888 additions and 807 deletions

View File

@@ -14,14 +14,15 @@ test-db:
docker compose up -d postgres
@sleep 3
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
$(GO) test -race -count=1 ./internal/db/
$(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/
lint:
$(GO) vet ./...
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping"
generate:
@echo "TODO: oapi-codegen + sqlc generation"
$(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
-config api/codegen.yaml api/openapi.yaml
migrate:
$(GO) run ./cmd/oikos migrate

8
api/codegen.yaml Normal file
View File

@@ -0,0 +1,8 @@
# oapi-codegen config — `make generate` regenerates internal/httpapi/gen.
package: gen
output: internal/httpapi/gen/api.gen.go
generate:
models: true
chi-server: true
strict-server: true
embedded-spec: true

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,11 @@ import (
"os/signal"
"syscall"
"net/http"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/httpapi"
"github.com/dtoro/oikos/internal/observability"
"github.com/jackc/pgx/v5"
)
@@ -50,8 +53,10 @@ func main() {
os.Exit(1)
}
case "api":
slog.Info("api role not yet implemented (Phase 2)")
os.Exit(1)
if err := runAPI(ctx, cfg); err != nil {
slog.Error("api failed", "error", err)
os.Exit(1)
}
case "scheduler":
slog.Info("scheduler role not yet implemented (Phase 3)")
os.Exit(1)
@@ -194,6 +199,24 @@ func runSeed(ctx context.Context, cfg config.Config) error {
return nil
}
func runAPI(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
if err := pool.Migrate(ctx); err != nil {
return fmt.Errorf("migrations: %w", err)
}
err = httpapi.ListenAndServe(ctx, pool, cfg)
if err == http.ErrServerClosed {
return nil
}
return err
}
func runExport(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {

14
go.mod
View File

@@ -3,17 +3,25 @@ module github.com/dtoro/oikos
go 1.26.3
require (
github.com/getkin/kin-openapi v0.140.0
github.com/go-chi/chi/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/oapi-codegen/runtime v1.4.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/oasdiff/yaml v0.1.0 // indirect
github.com/oasdiff/yaml3 v0.0.13 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

37
go.sum
View File

@@ -1,7 +1,22 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -12,23 +27,35 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE=
github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -17,7 +17,8 @@ type Config struct {
APIListen string // :8090
APIEnv string // dev, prod
// MCP
// Auth (interim Phase 2: static bearer tokens; OIDC JWT later)
APIToken string // operator/CI bearer token for the REST API
MCPBearerToken string // shared secret for Hermes→API MCP calls
// Observability
@@ -54,6 +55,9 @@ func FromEnv() Config {
if v := os.Getenv("OIKOS_ENV"); v != "" {
c.APIEnv = v
}
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
c.APIToken = v
}
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
c.MCPBearerToken = v
}

View File

@@ -0,0 +1,292 @@
package httpapi
// API integration tests. Guarded by OIKOS_TEST_DATABASE_URL (see
// internal/db/integration_test.go); run via `make test-db-all` or plain
// `go test` with the env var set.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/jackc/pgx/v5"
)
func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_api_test_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
qi := strings.Index(baseURL, "?")
base, params := baseURL, ""
if qi >= 0 {
base, params = baseURL[:qi], baseURL[qi:]
}
testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
admin, err := pgx.Connect(ctx, baseURL)
if err == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
content, err := os.ReadFile("../../seeds/" + f)
if err != nil {
t.Fatalf("read seed %s: %v", f, err)
}
name := f
err = pool.SeedIngest(ctx, name, content,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
var err error
switch name {
case "ontology.yaml":
_, err = db.IngestOntologySeed(ctx, tx, data)
case "inventory.yaml":
_, err = db.IngestInventorySeed(ctx, tx, data)
case "policy.yaml":
_, err = db.IngestPolicySeed(ctx, tx, data)
}
return err
})
if err != nil {
t.Fatalf("ingest %s: %v", f, err)
}
}
return NewHandler(pool, cfg)
}
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var body map[string]any
json.Unmarshal(rec.Body.Bytes(), &body)
return rec, body
}
func devConfig() config.Config {
c := config.Default()
c.APIEnv = "dev" // no tokens → dev-open auth
return c
}
func TestAPIEndToEnd(t *testing.T) {
h := newTestHandler(t, devConfig())
t.Run("healthz", func(t *testing.T) {
rec, body := get(t, h, "/healthz", nil)
if rec.Code != 200 || body["status"] != "ok" {
t.Fatalf("healthz = %d %v", rec.Code, body)
}
})
t.Run("list entities filtered by type", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/entities?type=service", nil)
if rec.Code != 200 {
t.Fatalf("status %d: %v", rec.Code, body)
}
items := body["items"].([]any)
if len(items) < 20 {
t.Errorf("expected 20+ services, got %d", len(items))
}
})
t.Run("type filter includes descendants via hierarchy", func(t *testing.T) {
// machine is abstract; proxmox-host/workstation/standalone-server descend from it
rec, body := get(t, h, "/api/v1/entities?type=machine", nil)
if rec.Code != 200 {
t.Fatalf("status %d: %v", rec.Code, body)
}
items := body["items"].([]any)
if len(items) < 5 {
t.Errorf("expected 5+ machines (hubris, strong, vps, 2 workstations), got %d", len(items))
}
})
t.Run("pagination", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/entities?limit=10", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(body["items"].([]any)) != 10 {
t.Fatalf("limit=10 returned %d", len(body["items"].([]any)))
}
cursor, _ := body["next_cursor"].(string)
if cursor == "" {
t.Fatal("expected next_cursor")
}
rec2, body2 := get(t, h, "/api/v1/entities?limit=10&cursor="+cursor, nil)
if rec2.Code != 200 {
t.Fatalf("page 2 status %d", rec2.Code)
}
first := body2["items"].([]any)[0].(map[string]any)["slug"].(string)
if first <= cursor {
t.Errorf("page 2 first slug %q not after cursor %q", first, cursor)
}
})
t.Run("get entity by slug with etag", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/entities/host:hubris", nil)
if rec.Code != 200 || body["slug"] != "host:hubris" {
t.Fatalf("get by slug = %d %v", rec.Code, body["slug"])
}
if rec.Header().Get("ETag") == "" {
t.Error("missing ETag header")
}
// and by UUID
id := body["id"].(string)
rec2, body2 := get(t, h, "/api/v1/entities/"+id, nil)
if rec2.Code != 200 || body2["slug"] != "host:hubris" {
t.Errorf("get by uuid = %d %v", rec2.Code, body2["slug"])
}
})
t.Run("unknown entity is 404 problem+json", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/entities/host:nonexistent", nil)
if rec.Code != 404 {
t.Fatalf("status %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("content-type %q", ct)
}
if body["title"] != "not found" {
t.Errorf("problem title %v", body["title"])
}
})
t.Run("relations", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/entities/host:hubris/relations?rel_type=hosts&direction=out", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
items := body["items"].([]any)
if len(items) < 10 {
t.Errorf("hubris hosts %d guests, want 10+", len(items))
}
})
t.Run("blast radius", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/entities/lxc:caddy/blast-radius?depth=2", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(body["items"].([]any)) < 2 {
t.Errorf("blast radius too small: %v", body["items"])
}
})
t.Run("graph", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/graph?root=service:paperless&depth=2", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(body["nodes"].([]any)) < 2 || len(body["edges"].([]any)) < 1 {
t.Errorf("graph too small: %d nodes %d edges",
len(body["nodes"].([]any)), len(body["edges"].([]any)))
}
})
t.Run("ontology", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/ontology", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(body["entity_types"].([]any)) != 59 {
t.Errorf("entity_types = %d, want 59", len(body["entity_types"].([]any)))
}
if len(body["lifecycles"].([]any)) != 6 {
t.Errorf("lifecycles = %d, want 6", len(body["lifecycles"].([]any)))
}
})
t.Run("signals empty list", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/signals", nil)
if rec.Code != 200 {
t.Fatalf("status %d: %v", rec.Code, body)
}
})
t.Run("export returns real seeds", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/export", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(body["inventory"].(string)) < 1000 {
t.Errorf("inventory export suspiciously small")
}
})
t.Run("unimplemented endpoint is 501 problem+json", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/patterns", nil)
if rec.Code != 501 {
t.Fatalf("status %d, want 501", rec.Code)
}
if body["title"] != "not implemented" {
t.Errorf("problem title %v", body["title"])
}
})
}
func TestAPIBearerAuth(t *testing.T) {
cfg := devConfig()
cfg.APIToken = "test-token-123"
h := newTestHandler(t, cfg)
// healthz stays open
rec, _ := get(t, h, "/healthz", nil)
if rec.Code != 200 {
t.Errorf("healthz with auth enabled = %d, want 200", rec.Code)
}
// API requires the token
rec, body := get(t, h, "/api/v1/entities", nil)
if rec.Code != 401 {
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
}
rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer wrong"})
if rec.Code != 401 {
t.Errorf("wrong token = %d, want 401", rec.Code)
}
rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer test-token-123"})
if rec.Code != 200 {
t.Errorf("valid token = %d, want 200", rec.Code)
}
}

File diff suppressed because it is too large Load Diff

517
internal/httpapi/impl.go Normal file
View File

@@ -0,0 +1,517 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
defaultLimit = 50
maxLimit = 200
graphNodeCap = 500
)
func clampLimit(l *int) int {
if l == nil {
return defaultLimit
}
if *l < 1 {
return 1
}
if *l > maxLimit {
return maxLimit
}
return *l
}
// resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID.
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
if id, err := uuid.Parse(idOrSlug); err == nil {
var found uuid.UUID
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE id = $1", id).Scan(&found)
if err == pgx.ErrNoRows {
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
}
return found, err
}
var id uuid.UUID
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id)
if err == pgx.ErrNoRows {
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
}
return id, err
}
// entityCols requires the entities table to be aliased as `e`.
const entityCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes,
e.maintenance_until, e.version, e.created_at, e.updated_at`
func scanEntity(row pgx.Row) (gen.Entity, error) {
var e gen.Entity
var state *string
var attrsJSON []byte
var maint *time.Time
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt)
if err != nil {
return e, err
}
e.State = state
e.MaintenanceUntil = maint
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
e.Attributes = &attrs
}
return e, nil
}
// ─── Entities ─────────────────────────────────────────────────────────
func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) {
limit := clampLimit(req.Params.Limit)
// Type filter includes descendants via the parent hierarchy (R3-1).
query := `
WITH RECURSIVE tt AS (
SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1
UNION
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
WHERE $1::text IS NOT NULL
)
SELECT ` + entityCols + ` FROM entities e
JOIN entity_types et ON et.name = e.type
WHERE e.type IN (SELECT name FROM tt)
AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR et.domain = $3)
AND ($4::text IS NULL OR et.layer = $4)
AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%')
AND ($6::text IS NULL OR e.slug > $6)
ORDER BY e.slug
LIMIT $7`
rows, err := s.pool.Query(ctx, query,
req.Params.Type, req.Params.State, req.Params.Domain, req.Params.Layer,
req.Params.Q, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []gen.Entity
for rows.Next() {
e, err := scanEntity(rows)
if err != nil {
return nil, err
}
items = append(items, e)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Entity{}
}
return gen.ListEntities200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
e, err := scanEntity(s.pool.QueryRow(ctx,
"SELECT "+entityCols+" FROM entities e WHERE e.id = $1", id))
if err != nil {
return nil, err
}
return gen.GetEntity200JSONResponse{
Body: e,
Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Version) + `"`},
}, nil
}
func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
dir := "both"
if req.Params.Direction != nil {
dir = string(*req.Params.Direction)
}
rows, err := s.pool.Query(ctx, `
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND (($3 IN ('out','both') AND r.source_id = $1)
OR ($3 IN ('in','both') AND r.target_id = $1))
AND ($2::text IS NULL OR r.type = $2)
ORDER BY r.type, se.slug, te.slug`,
id, req.Params.RelType, dir)
if err != nil {
return nil, err
}
items, err := scanRelationships(rows)
if err != nil {
return nil, err
}
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
}
func scanRelationships(rows pgx.Rows) ([]gen.Relationship, error) {
defer rows.Close()
items := []gen.Relationship{}
for rows.Next() {
var rel gen.Relationship
var attrsJSON []byte
if err := rows.Scan(&rel.Source, &rel.Target, &rel.Type,
&attrsJSON, &rel.ValidFrom, &rel.ValidTo); err != nil {
return nil, err
}
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
rel.Attributes = &attrs
}
items = append(items, rel)
}
return items, rows.Err()
}
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
depth := 3
if req.Params.Depth != nil {
depth = *req.Params.Depth
}
rows, err := s.pool.Query(ctx, `
SELECT `+entityCols+`, b.depth
FROM blast_radius($1, $2) b
JOIN entities e ON e.id = b.entity_id
ORDER BY b.depth, e.slug`, id, depth)
if err != nil {
return nil, err
}
defer rows.Close()
resp := gen.GetBlastRadius200JSONResponse{Items: []struct {
Depth int `json:"depth"`
Entity gen.Entity `json:"entity"`
}{}}
for rows.Next() {
var e gen.Entity
var state *string
var attrsJSON []byte
var maint *time.Time
var d int
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &d); err != nil {
return nil, err
}
e.State = state
e.MaintenanceUntil = maint
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
e.Attributes = &attrs
}
resp.Items = append(resp.Items, struct {
Depth int `json:"depth"`
Entity gen.Entity `json:"entity"`
}{Depth: d, Entity: e})
}
return resp, rows.Err()
}
func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) {
depth := 2
if req.Params.Depth != nil {
depth = *req.Params.Depth
}
var nodes []gen.Entity
var err error
truncated := false
if req.Params.Root != nil && *req.Params.Root != "" {
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
if rerr != nil {
return nil, rerr
}
nodes, err = s.queryEntities(ctx, `
SELECT `+entityCols+`
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
} else {
nodes, err = s.queryEntities(ctx, `
SELECT `+entityCols+` FROM entities e ORDER BY e.slug LIMIT $1`,
graphNodeCap+1)
if err == nil && len(nodes) > graphNodeCap {
nodes = nodes[:graphNodeCap]
truncated = true
}
}
if err != nil {
return nil, err
}
ids := make([]uuid.UUID, len(nodes))
for i, n := range nodes {
ids[i] = uuid.UUID(n.Id)
}
rows, err := s.pool.Query(ctx, `
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND r.source_id = ANY($1) AND r.target_id = ANY($1)
AND ($2::text[] IS NULL OR r.type = ANY($2))
ORDER BY r.type, se.slug, te.slug`, ids, req.Params.RelType)
if err != nil {
return nil, err
}
edges, err := scanRelationships(rows)
if err != nil {
return nil, err
}
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
if truncated {
resp.Truncated = &truncated
}
return resp, nil
}
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Entity{}
for rows.Next() {
e, err := scanEntity(rows)
if err != nil {
return nil, err
}
items = append(items, e)
}
return items, rows.Err()
}
// ─── Ontology ─────────────────────────────────────────────────────────
func (s *Server) GetOntology(ctx context.Context, req gen.GetOntologyRequestObject) (gen.GetOntologyResponseObject, error) {
resp := gen.GetOntology200JSONResponse{
EntityTypes: []gen.EntityType{},
RelationshipTypes: []gen.RelationshipType{},
Lifecycles: []gen.LifecycleDef{},
}
rows, err := s.pool.Query(ctx, `
SELECT name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, schema_version, status
FROM entity_types ORDER BY name`)
if err != nil {
return nil, err
}
for rows.Next() {
var et gen.EntityType
var schemaVersion int
var schemaJSON []byte
if err := rows.Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain,
&et.Layer, &et.Description, &et.LifecycleId, &schemaJSON,
&schemaVersion, &et.Status); err != nil {
rows.Close()
return nil, err
}
et.SchemaVersion = &schemaVersion
var schema map[string]any
if len(schemaJSON) > 0 && json.Unmarshal(schemaJSON, &schema) == nil && schema != nil {
et.AttributeSchema = &schema
}
resp.EntityTypes = append(resp.EntityTypes, et)
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
rows, err = s.pool.Query(ctx, `
SELECT name, inverse, source_type, target_type, cardinality, description
FROM relationship_types ORDER BY name`)
if err != nil {
return nil, err
}
for rows.Next() {
var rt gen.RelationshipType
if err := rows.Scan(&rt.Name, &rt.Inverse, &rt.SourceType, &rt.TargetType,
&rt.Cardinality, &rt.Description); err != nil {
rows.Close()
return nil, err
}
resp.RelationshipTypes = append(resp.RelationshipTypes, rt)
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
rows, err = s.pool.Query(ctx, `
SELECT id, states, default_state, terminal_states, transitions
FROM lifecycle_defs ORDER BY id`)
if err != nil {
return nil, err
}
for rows.Next() {
var lc gen.LifecycleDef
var terminal []string
var transJSON []byte
if err := rows.Scan(&lc.Id, &lc.States, &lc.DefaultState, &terminal, &transJSON); err != nil {
rows.Close()
return nil, err
}
lc.TerminalStates = &terminal
if err := json.Unmarshal(transJSON, &lc.Transitions); err != nil {
rows.Close()
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.Id, err)
}
resp.Lifecycles = append(resp.Lifecycles, lc)
}
rows.Close()
return resp, rows.Err()
}
// ─── Signals ──────────────────────────────────────────────────────────
func (s *Server) ListSignals(ctx context.Context, req gen.ListSignalsRequestObject) (gen.ListSignalsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
te.slug, sig.check_id::text, sig.evidence, sig.likely_cause,
sig.occurrence_count, sig.flap_count, sig.hold_down_until,
sig.mute_until, sig.first_seen_at, sig.last_seen_at
FROM signals sig
JOIN entities se ON se.id = sig.entity_id
LEFT JOIN entities te ON te.id = sig.target_entity_id
WHERE ($1::text IS NULL OR sig.state = $1)
AND ($2::text IS NULL OR sig.severity = $2)
AND ($3::text IS NULL OR te.slug = $3)
AND ($4::text IS NULL OR sig.kind = $4)
AND ($5::text IS NULL OR se.slug > $5)
ORDER BY se.slug
LIMIT $6`,
req.Params.State, (*string)(req.Params.Severity), req.Params.EntityId,
req.Params.Kind, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Signal{}
for rows.Next() {
var sig gen.Signal
var flap int
if err := rows.Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
&sig.OccurrenceCount, &flap, &sig.HoldDownUntil,
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt); err != nil {
return nil, err
}
sig.FlapCount = &flap
items = append(items, sig)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
return gen.ListSignals200JSONResponse{Items: items, NextCursor: next}, nil
}
// ─── Observability + system ───────────────────────────────────────────
func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthRequestObject) (gen.GetFleetHealthResponseObject, error) {
resp := gen.GetFleetHealth200JSONResponse{}
resp.Entities = []struct {
Health gen.HealthSummaryEntitiesHealth `json:"health"`
LastCheckAt *time.Time `json:"last_check_at"`
Slug string `json:"slug"`
Trend *gen.HealthSummaryEntitiesTrend `json:"trend"`
Type string `json:"type"`
}{}
rows, err := s.pool.Query(ctx, `
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
ORDER BY e.slug`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var slug, typ, health string
var lastCheck *time.Time
if err := rows.Scan(&slug, &typ, &health, &lastCheck); err != nil {
return nil, err
}
switch health {
case "healthy":
resp.Summary.Healthy++
case "degraded":
resp.Summary.Degraded++
case "down":
resp.Summary.Down++
default:
resp.Summary.Unknown++
}
resp.Entities = append(resp.Entities, struct {
Health gen.HealthSummaryEntitiesHealth `json:"health"`
LastCheckAt *time.Time `json:"last_check_at"`
Slug string `json:"slug"`
Trend *gen.HealthSummaryEntitiesTrend `json:"trend"`
Type string `json:"type"`
}{
Health: gen.HealthSummaryEntitiesHealth(health),
LastCheckAt: lastCheck,
Slug: slug,
Type: typ,
})
}
return resp, rows.Err()
}
func (s *Server) ExportSeeds(ctx context.Context, req gen.ExportSeedsRequestObject) (gen.ExportSeedsResponseObject, error) {
exports, err := db.ExportToYAML(ctx, s.pool)
if err != nil {
return nil, err
}
return gen.ExportSeeds200JSONResponse{
Ontology: string(exports["ontology.yaml"]),
Inventory: string(exports["inventory.yaml"]),
Policy: string(exports["policy.yaml"]),
}, nil
}

View File

@@ -0,0 +1,69 @@
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
)
// errNotImplemented marks endpoints stubbed for later phases.
var errNotImplemented = errors.New("not implemented yet")
// statusFor maps domain sentinel errors to HTTP status codes (plan SG11).
func statusFor(err error) (status int, title string) {
switch {
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound, "not found"
case errors.Is(err, domain.ErrInvalidTransition):
return http.StatusConflict, "invalid lifecycle transition"
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrAlreadyExists):
return http.StatusConflict, "conflict"
case errors.Is(err, domain.ErrCardinality):
return http.StatusConflict, "relationship cardinality violation"
case errors.Is(err, domain.ErrAbstractType), errors.Is(err, domain.ErrInvalidEdge):
return http.StatusUnprocessableEntity, "ontology validation failed"
case errors.Is(err, domain.ErrApprovalRequired):
return http.StatusForbidden, "operator approval required"
case errors.Is(err, domain.ErrAutonomyBlocked):
return http.StatusForbidden, "autonomy policy blocks this action"
case errors.Is(err, domain.ErrCircuitOpen):
return http.StatusServiceUnavailable, "circuit breaker open"
case errors.Is(err, errNotImplemented):
return http.StatusNotImplemented, "not implemented"
default:
return http.StatusInternalServerError, "internal error"
}
}
// writeProblem writes an RFC 9457 problem+json response.
func writeProblem(w http.ResponseWriter, r *http.Request, status int, title, detail string) {
instance := r.URL.Path
p := gen.Problem{
Status: status,
Title: title,
Instance: &instance,
}
if detail != "" {
p.Detail = &detail
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(p)
}
// writeProblemFromErr maps an error to a problem+json response. Internal
// error details are logged server-side, never leaked to clients.
func writeProblemFromErr(w http.ResponseWriter, r *http.Request, err error) {
status, title := statusFor(err)
detail := ""
if status != http.StatusInternalServerError {
detail = err.Error()
} else {
slog.Error("internal error", "method", r.Method, "path", r.URL.Path, "error", err)
}
writeProblem(w, r, status, title, detail)
}

147
internal/httpapi/server.go Normal file
View 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)
}
}

163
internal/httpapi/stubs.go Normal file
View File

@@ -0,0 +1,163 @@
package httpapi
// Stubs for operations landing later in Phase 2/3. Each returns 501
// problem+json via errNotImplemented. Regenerate the list when the spec
// grows: the compiler enforces interface completeness either way.
import (
"context"
"github.com/dtoro/oikos/internal/httpapi/gen"
)
func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListApprovals(ctx context.Context, request gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) DecideApproval(ctx context.Context, request gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) QueryAudit(ctx context.Context, request gen.QueryAuditRequestObject) (gen.QueryAuditResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListChecks(ctx context.Context, request gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) CreateCheck(ctx context.Context, request gen.CreateCheckRequestObject) (gen.CreateCheckResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchCheck(ctx context.Context, request gen.PatchCheckRequestObject) (gen.PatchCheckResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListClassifications(ctx context.Context, request gen.ListClassificationsRequestObject) (gen.ListClassificationsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) CreateEntity(ctx context.Context, request gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchEntity(ctx context.Context, request gen.PatchEntityRequestObject) (gen.PatchEntityResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) QueryEvents(ctx context.Context, request gen.QueryEventsRequestObject) (gen.QueryEventsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) StreamEvents(ctx context.Context, request gen.StreamEventsRequestObject) (gen.StreamEventsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListExecutions(ctx context.Context, request gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) RequestExecution(ctx context.Context, request gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) GetExecution(ctx context.Context, request gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) CancelExecution(ctx context.Context, request gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKnowledgeRequestObject) (gen.GetEntityKnowledgeResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) QueryMetrics(ctx context.Context, request gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) CreateEntityType(ctx context.Context, request gen.CreateEntityTypeRequestObject) (gen.CreateEntityTypeResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchEntityType(ctx context.Context, request gen.PatchEntityTypeRequestObject) (gen.PatchEntityTypeResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListPatterns(ctx context.Context, request gen.ListPatternsRequestObject) (gen.ListPatternsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchPattern(ctx context.Context, request gen.PatchPatternRequestObject) (gen.PatchPatternResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListApprovalRules(ctx context.Context, request gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) CreateApprovalRule(ctx context.Context, request gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchApprovalRule(ctx context.Context, request gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) GetAutonomySettings(ctx context.Context, request gen.GetAutonomySettingsRequestObject) (gen.GetAutonomySettingsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchAutonomySettings(ctx context.Context, request gen.PatchAutonomySettingsRequestObject) (gen.PatchAutonomySettingsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListRiskClasses(ctx context.Context, request gen.ListRiskClassesRequestObject) (gen.ListRiskClassesResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) EndRelationship(ctx context.Context, request gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) CreateRelationship(ctx context.Context, request gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) AckSignal(ctx context.Context, request gen.AckSignalRequestObject) (gen.AckSignalResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) MuteSignal(ctx context.Context, request gen.MuteSignalRequestObject) (gen.MuteSignalResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ResolveSignal(ctx context.Context, request gen.ResolveSignalRequestObject) (gen.ResolveSignalResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListSkills(ctx context.Context, request gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) PatchSkill(ctx context.Context, request gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) ListSkillVersions(ctx context.Context, request gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) {
return nil, errNotImplemented
}
func (s *Server) GetTrends(ctx context.Context, request gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) {
return nil, errNotImplemented
}