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.
238 lines
6.0 KiB
Go
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
|
|
} |