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:
@@ -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
|
||||
}
|
||||
|
||||
292
internal/httpapi/api_test.go
Normal file
292
internal/httpapi/api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
7567
internal/httpapi/gen/api.gen.go
Normal file
7567
internal/httpapi/gen/api.gen.go
Normal file
File diff suppressed because it is too large
Load Diff
517
internal/httpapi/impl.go
Normal file
517
internal/httpapi/impl.go
Normal 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
|
||||
}
|
||||
69
internal/httpapi/problem.go
Normal file
69
internal/httpapi/problem.go
Normal 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
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)
|
||||
}
|
||||
}
|
||||
163
internal/httpapi/stubs.go
Normal file
163
internal/httpapi/stubs.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user