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 }