Files
oikos/internal/httpapi/entities.go
dtoro 65f415f9db
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 3d — ReadModels port, postgres impl, HTTP reads rewire
Problem: entity/relationship/graph/blast-radius reads were embedded
as inline SQL in the httpapi handlers, duplicating the recursive type
tree CTE, the blast_radius function call, and the topology-picking
query across the REST and MCP surfaces with no port abstraction.

Change:
- ports.ReadModels interface: ListEntities, GetEntity, GetEntityBySlug,
  GetEntityRelations, GetBlastRadius, GetGraph (with health),
  ListEntityTypes. Returns EntityWithHealth (domain.Entity + health
  from entity_status join) and domain.Relationship — no gen types
  in the port.
- adapters/postgres/readmodels.go: EntityReader implements ReadModels
  with the existing SQL verbatim (recursive type filter, blast_radius,
  most-connected-first topology, graph edge listing).
- httpapi/entities.go: ListEntities, GetEntity, GetEntityRelations,
  GetBlastRadius, GetGraph rewired to ReadModels. SQL moved to the
  adapter; handlers map domain/ports types to gen wire shapes.
  entityWithHealthToGen, sqlcEntityToGen helpers added.
- Old sqlcEntityToGen (sqlcgen.Entity → gen.Entity) preserved for
  client_lifecycle.go; mutation handlers use domainToGen.

Verification: go build/vet, full test suite (19 pkgs), DB integration
(postgres + mcp — both green). httpapi DB tests have the pre-existing
set of failures (TestAPIEndToEnd entity_types=60/501, TestPhase3*)
verified at ec11956.
2026-08-15 23:58:31 +02:00

238 lines
6.0 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"strconv"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/google/uuid"
)
func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) {
limit := clampLimit(req.Params.Limit)
filters := ports.EntityFilters{
Type: strOrEmpty(req.Params.Type),
State: strOrEmpty(req.Params.State),
Q: strOrEmpty(req.Params.Q),
Domain: strOrEmpty(req.Params.Domain),
Layer: strOrEmpty(req.Params.Layer),
Cursor: strOrEmpty(req.Params.Cursor),
Limit: limit,
}
items, next, err := s.readModels.ListEntities(ctx, filters)
if err != nil {
return nil, err
}
entities := make([]gen.Entity, len(items))
for i, e := range items {
entities[i] = entityWithHealthToGen(e)
}
if entities == nil {
entities = []gen.Entity{}
}
return gen.ListEntities200JSONResponse{Items: entities, NextCursor: &next}, nil
}
func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) {
uid, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
e, err := s.readModels.GetEntity(ctx, domain.UUID(uid.String()))
if err != nil {
return nil, err
}
entity := entityWithHealthToGen(e)
return gen.GetEntity200JSONResponse{
Body: entity,
Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Entity.Version) + `"`},
}, nil
}
func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) {
uid, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
dir := "both"
if req.Params.Direction != nil {
dir = string(*req.Params.Direction)
}
relType := strOrEmpty(req.Params.RelType)
rels, err := s.readModels.GetEntityRelations(ctx, domain.UUID(uid.String()), dir, relType)
if err != nil {
return nil, err
}
items := make([]gen.Relationship, len(rels))
for i, r := range rels {
items[i] = relToGen(r)
}
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
}
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
uid, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
depth := 3
if req.Params.Depth != nil {
depth = *req.Params.Depth
}
items, err := s.readModels.GetBlastRadius(ctx, domain.UUID(uid.String()), depth)
if err != nil {
return nil, err
}
resp := gen.GetBlastRadius200JSONResponse{Items: []struct {
Depth int `json:"depth"`
Entity gen.Entity `json:"entity"`
}{}}
for _, e := range items {
resp.Items = append(resp.Items, struct {
Depth int `json:"depth"`
Entity gen.Entity `json:"entity"`
}{Depth: 0, Entity: entityWithHealthToGen(e)})
}
return resp, nil
}
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 rootID *domain.UUID
if req.Params.Root != nil && *req.Params.Root != "" {
uid, err := s.resolveEntityID(ctx, *req.Params.Root)
if err != nil {
return nil, err
}
duid := domain.UUID(uid.String())
rootID = &duid
}
var relTypes []string
if req.Params.RelType != nil {
relTypes = *req.Params.RelType
}
nodes, edges, truncated, err := s.readModels.GetGraph(ctx, depth, rootID, relTypes, graphNodeCap)
if err != nil {
return nil, err
}
genNodes := make([]gen.Entity, len(nodes))
for i, n := range nodes {
genNodes[i] = entityWithHealthToGen(n)
}
genEdges := make([]gen.Relationship, len(edges))
for i, e := range edges {
genEdges[i] = relToGen(e)
}
resp := gen.GetGraph200JSONResponse{Nodes: genNodes, Edges: genEdges}
if truncated {
resp.Truncated = &truncated
}
if req.Params.Include != nil {
for _, inc := range *req.Params.Include {
if inc == gen.Status {
health := make(map[string]gen.GraphViewHealth, len(nodes))
for _, n := range nodes {
health[string(n.Entity.ID)] = gen.GraphViewHealth(n.Health)
}
resp.Health = &health
break
}
}
}
return resp, nil
}
func strOrEmpty(p *string) string {
if p == nil {
return ""
}
return *p
}
func entityWithHealthToGen(e ports.EntityWithHealth) gen.Entity {
id, _ := uuid.Parse(string(e.Entity.ID))
out := gen.Entity{
Id: id,
Slug: e.Entity.Slug,
Type: e.Entity.Type,
Name: e.Entity.Name,
Version: e.Entity.Version,
CreatedAt: e.Entity.CreatedAt,
UpdatedAt: e.Entity.UpdatedAt,
}
if e.Entity.State != "" {
out.State = &e.Entity.State
}
if e.Entity.MaintenanceUntil != nil {
out.MaintenanceUntil = e.Entity.MaintenanceUntil
}
if len(e.Entity.Attributes) > 0 {
attrs := e.Entity.Attributes
out.Attributes = &attrs
}
if e.Health != "" {
h := gen.EntityHealth(e.Health)
out.Health = &h
}
out.LastCheckAt = e.LastCheckAt
return out
}
func relToGen(r domain.Relationship) gen.Relationship {
var attrs *map[string]any
if len(r.Attributes) > 0 {
attrs = &r.Attributes
}
return gen.Relationship{
Source: string(r.SourceID),
Target: string(r.TargetID),
Type: r.Type,
Attributes: attrs,
ValidFrom: r.ValidFrom,
ValidTo: r.ValidTo,
}
}
// sqlcEntityToGen converts a sqlcgen entity to the wire shape. Used by
// client_lifecycle.go (enroll) — the mutation handlers use domainToGen.
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
}