package httpapi import ( "context" "crypto/sha256" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "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 } // ─── Signal mutations ──────────────────────────────────────────────── func (s *Server) AckSignal(ctx context.Context, req gen.AckSignalRequestObject) (gen.AckSignalResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var sig gen.Signal err = tx.QueryRow(ctx, ` UPDATE signals SET state = 'acknowledged', updated_at = now() WHERE entity_id = $1 AND state IN ('raised','failed') RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1), kind, severity, 'acknowledged', (SELECT slug FROM entities WHERE id = target_entity_id), check_id::text, evidence, likely_cause, occurrence_count, flap_count, hold_down_until, mute_until, first_seen_at, last_seen_at`, id).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State, &sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause, &sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil, &sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt) if err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: signal %s not in a state that can be acknowledged", domain.ErrInvalidTransition, req.Id) } return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return gen.AckSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil } func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequestObject) (gen.ResolveSignalResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var sig gen.Signal err = tx.QueryRow(ctx, ` UPDATE signals SET state = 'resolved', updated_at = now() WHERE entity_id = $1 AND state IN ('raised','acknowledged','acting','failed') RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1), kind, severity, 'resolved', (SELECT slug FROM entities WHERE id = target_entity_id), check_id::text, evidence, likely_cause, occurrence_count, flap_count, hold_down_until, mute_until, first_seen_at, last_seen_at`, id).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State, &sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause, &sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil, &sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt) if err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: signal %s not in a state that can be resolved", domain.ErrInvalidTransition, req.Id) } return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return gen.ResolveSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil } func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject) (gen.MuteSignalResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var sig gen.Signal err = tx.QueryRow(ctx, ` UPDATE signals SET state = 'muted', mute_until = $2, updated_at = now() WHERE entity_id = $1 AND state IN ('raised','acknowledged') RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1), kind, severity, 'muted', (SELECT slug FROM entities WHERE id = target_entity_id), check_id::text, evidence, likely_cause, occurrence_count, flap_count, hold_down_until, mute_until, first_seen_at, last_seen_at`, id, req.Body.MuteUntil).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State, &sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause, &sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil, &sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt) if err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: signal %s not in a state that can be muted", domain.ErrInvalidTransition, req.Id) } return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return gen.MuteSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil } // ─── Observability reads ───────────────────────────────────────────── func (s *Server) QueryEvents(ctx context.Context, req gen.QueryEventsRequestObject) (gen.QueryEventsResponseObject, error) { limit := clampLimit(req.Params.Limit) var eventType, entityID, severity, correlationID *string if req.Params.Type != nil { eventType = req.Params.Type } if req.Params.EntityId != nil { entityID = req.Params.EntityId } if req.Params.Severity != nil { severity = req.Params.Severity } if req.Params.CorrelationId != nil { correlationID = req.Params.CorrelationId } rows, err := s.pool.Query(ctx, ` SELECT id, ts, type, entity_id::text, severity, source, data, correlation_id FROM events WHERE ($1::text IS NULL OR type = $1) AND ($2::text IS NULL OR entity_id::text = $2) AND ($3::text IS NULL OR severity = $3) AND ($4::text IS NULL OR correlation_id = $4) AND ($5::timestamptz IS NULL OR ts >= $5) AND ($6::timestamptz IS NULL OR ts <= $6) ORDER BY ts DESC LIMIT $7`, eventType, entityID, severity, correlationID, req.Params.From, req.Params.To, limit) if err != nil { return nil, err } defer rows.Close() items := []gen.Event{} for rows.Next() { var e gen.Event var dataBytes []byte var entID, corrID *string if err := rows.Scan(&e.Id, &e.Ts, &e.Type, &entID, &e.Severity, &e.Source, &dataBytes, &corrID); err != nil { return nil, err } e.EntityId = entID e.CorrelationId = corrID var data map[string]any if json.Unmarshal(dataBytes, &data) == nil { e.Data = &data } items = append(items, e) } return gen.QueryEvents200JSONResponse{Items: items}, rows.Err() } func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject) (gen.QueryAuditResponseObject, error) { limit := clampLimit(req.Params.Limit) var actorType, actorID, action, entityID, correlationID *string if req.Params.ActorType != nil { actorType = req.Params.ActorType } if req.Params.ActorId != nil { actorID = req.Params.ActorId } if req.Params.Action != nil { action = req.Params.Action } if req.Params.EntityId != nil { entityID = req.Params.EntityId } if req.Params.CorrelationId != nil { correlationID = req.Params.CorrelationId } rows, err := s.pool.Query(ctx, ` SELECT id, ts, actor_type, actor_id::text, action, entity_id::text, method, path, status_code, detail, source_ip, correlation_id FROM audit_log WHERE ($1::text IS NULL OR actor_type = $1) AND ($2::text IS NULL OR actor_id::text = $2) AND ($3::text IS NULL OR action = $3) AND ($4::text IS NULL OR entity_id::text = $4) AND ($5::text IS NULL OR correlation_id = $5) AND ($6::timestamptz IS NULL OR ts >= $6) AND ($7::timestamptz IS NULL OR ts <= $7) ORDER BY ts DESC LIMIT $8`, actorType, actorID, action, entityID, correlationID, req.Params.From, req.Params.To, limit) if err != nil { return nil, err } defer rows.Close() items := []gen.AuditEntry{} for rows.Next() { var a gen.AuditEntry var detailBytes []byte var actID, entID, method, path, sourceIP, corrID *string var statusCode *int if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID, &method, &path, &statusCode, &detailBytes, &sourceIP, &corrID); err != nil { return nil, err } a.ActorId = actID a.EntityId = entID a.Method = method a.Path = path a.StatusCode = statusCode a.SourceIp = sourceIP a.CorrelationId = corrID var detail map[string]any if json.Unmarshal(detailBytes, &detail) == nil { a.Detail = &detail } items = append(items, a) } return gen.QueryAudit200JSONResponse{Items: items}, rows.Err() } // ─── Entity mutations ────────────────────────────────────────────── func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) { if req.Body == nil { return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) } // Check idempotency if a key was provided. actor := "operator" var bodyHash string if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" { key := *req.Params.IdempotencyKey q := sqlcgen.New(s.pool) cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{ Actor: actor, Key: key, }) if err == nil { // Verify the request body hasn't changed. bodyJSON, _ := json.Marshal(req.Body) bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON)) if cached.RequestHash != bodyHash { return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key) } // Replay the cached response. if cached.ResponseCode != nil && *cached.ResponseCode == 201 { var entity gen.Entity if len(cached.ResponseBody) > 0 { if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil { return nil, fmt.Errorf("unmarshal cached response: %w", err) } } return gen.CreateEntity201JSONResponse{ Body: entity, Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`}, }, nil } // Forward cached error response. return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{ Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"}, StatusCode: int(*cached.ResponseCode), }, nil } } id := uuid.New() slug := req.Body.Slug if slug == "" { slug = req.Body.Type + ":" + req.Body.Name } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) q := sqlcgen.New(tx) // Validate type exists and is NOT abstract. var isAbstract bool if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, req.Body.Type).Scan(&isAbstract); err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Body.Type) } return nil, err } if isAbstract { return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type) } // Get default state from lifecycle. var defaultState *string var lcDefault string if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld JOIN entity_types et ON et.lifecycle_id = ld.id WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil { defaultState = &lcDefault } state := req.Body.State if state == nil && defaultState != nil { state = defaultState } var attrsJSON []byte if req.Body.Attributes != nil { attrsJSON, _ = json.Marshal(req.Body.Attributes) } // Insert the entity. inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{ ID: id, Slug: slug, Type: req.Body.Type, Name: req.Body.Name, State: state, Attributes: attrsJSON, }) if err != nil { // Duplicate slug. if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { return nil, fmt.Errorf("%w: slug %q already exists", domain.ErrAlreadyExists, slug) } return nil, err } // Convert sqlcgen.Entity → gen.Entity. entity := sqlcEntityToGen(inserted) // Cache idempotent response. if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" { respBody, _ := json.Marshal(entity) code := int32(201) if bodyHash == "" { bodyJSON, _ := json.Marshal(req.Body) bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON)) } if putErr := q.PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{ Actor: actor, Key: *req.Params.IdempotencyKey, RequestHash: bodyHash, ResponseCode: &code, ResponseBody: respBody, }); putErr != nil { return nil, putErr } } // Audit. entityID := inserted.ID if auditErr := observability.Audit(ctx, q, "operator", actor, "create", &entityID, "POST", "/api/v1/entities", "", map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil { return nil, auditErr } if eventErr := observability.Event(ctx, q, "entity.created", &entityID, "info", "oikos-api", "", map[string]any{"slug": slug, "type": req.Body.Type}); eventErr != nil { return nil, eventErr } if err := tx.Commit(ctx); err != nil { return nil, err } return gen.CreateEntity201JSONResponse{ Body: entity, Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`}, }, nil } func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObject) (gen.PatchEntityResponseObject, error) { if req.Body == nil { return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) } id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } // Parse If-Match header (quoted version string). ifMatch := strings.Trim(req.Params.IfMatch, `"`) expectedVersion, err := strconv.Atoi(ifMatch) if err != nil { return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch) } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // Get current entity for version check + lifecycle validation. current, err := sqlcgen.New(tx).GetEntityByID(ctx, id) if err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id) } return nil, err } if int(current.Version) != expectedVersion { return nil, fmt.Errorf("%w: expected version %d, current version %d", domain.ErrConflict, expectedVersion, current.Version) } // Validate lifecycle transition if state is being changed. if req.Body.State != nil && *req.Body.State != "" { // Get lifecycle def for the entity's type. lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type) if err != nil { if err == pgx.ErrNoRows { // No lifecycle defined — any state is allowed. _ = lc } else { return nil, err } } else { // Check the transition is valid. var transitions map[string][]string if err := json.Unmarshal(lc.Transitions, &transitions); err != nil { return nil, fmt.Errorf("parse lifecycle transitions: %w", err) } fromState := "" if current.State != nil { fromState = *current.State } toState := *req.Body.State if allowed, ok := transitions[fromState]; ok { found := false for _, s := range allowed { if s == toState { found = true break } } if !found { return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState) } } else if fromState != "" { // No transitions defined from current state. return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState) } } } // Check idempotency (note: the spec doesn't define Idempotency-Key for PATCH, // but we handle it if the generated code ever adds it). // For now, no idempotency check on PATCH. // Marshal attributes if provided. var attrsJSON []byte if req.Body.Attributes != nil { attrsJSON, _ = json.Marshal(req.Body.Attributes) } // Perform the update via sqlcgen. q := sqlcgen.New(tx) updated, err := q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{ Name: req.Body.Name, State: req.Body.State, Attributes: attrsJSON, SetMaintenance: req.Body.MaintenanceUntil != nil, MaintenanceUntil: req.Body.MaintenanceUntil, ID: id, Version: int32(expectedVersion), }) if err != nil { if err == pgx.ErrNoRows { // Version mismatch or entity not found. return nil, fmt.Errorf("%w: entity was modified concurrently", domain.ErrConflict) } return nil, err } entity := sqlcEntityToGen(updated) // Audit. if auditErr := observability.Audit(ctx, q, "operator", "operator", "patch", &id, "PATCH", "/api/v1/entities/"+req.Id, "", map[string]any{"version": expectedVersion}); auditErr != nil { return nil, auditErr } if eventErr := observability.Event(ctx, q, "entity.updated", &id, "info", "oikos-api", "", map[string]any{"slug": entity.Slug, "type": entity.Type, "version": updated.Version}); eventErr != nil { return nil, eventErr } if err := tx.Commit(ctx); err != nil { return nil, err } return gen.PatchEntity200JSONResponse{ Body: entity, Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`}, }, nil } // sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity. func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity { out := gen.Entity{ Id: e.ID, Slug: e.Slug, Type: e.Type, Name: e.Name, State: e.State, Version: int(e.Version), CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, } if e.MaintenanceUntil != nil { out.MaintenanceUntil = e.MaintenanceUntil } if len(e.Attributes) > 0 { var attrs map[string]any if json.Unmarshal(e.Attributes, &attrs) == nil && len(attrs) > 0 { out.Attributes = &attrs } } return out }