feat: Phase 4 — RelationshipService, postgres RelRepo, converged edges
Problem: relationship create/end existed as three drifted copies (HTTP CreateRelationship/EndRelationship, MCP create_relationship/ end_relationship) with inline SQL, no ontology edge validation on either path, and no audit on the MCP path. Change: - Internal/adapters/postgres/repositories.go: RelRepo implements ports.RelationshipRepository (Create/End/ListFor) over the pool, with in-tx upsert + audit/event side effects on Create. - Internal/core/app/relationships.go: RelationshipService validates edges against the cached ontology TypeTree (tree.ValidateEdge) and delegates the tx to the repository. The adapter resolves slug→entity and extracts types before calling the service. - HTTP CreateRelationship: resolves source/target via ReadModels, passes resolved types to RelationshipService for edge validation. EndRelationship calls the service directly (audit stays in the adapter for End — a simple toggle with no ontology check). - MCP create_relationship/end_relationship: rewired to the service (pool resolves entity IDs inline for the tool handlers; the service validates edges and writes audit). The MCP path now gets ontology validation and audit coverage for the first time. - Composition root: RelationshipService built with RelRepo + Ontology and wired through httpapi.NewHandler, ListenAndServe, and MCP constructors. Verification: go build/vet, full test suite (19 pkgs, DB integration postgres+mcp green).
This commit is contained in:
@@ -2,121 +2,70 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/core/domain"
|
||||
"github.com/dtoro/oikos/internal/core/app"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
)
|
||||
|
||||
// ─── Relationships ─────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
sourceID, err := s.resolveEntityID(ctx, req.Body.Source)
|
||||
// Resolve source and target entities for their types (edge validation).
|
||||
src, err := s.readModels.GetEntityBySlug(ctx, req.Body.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
|
||||
tgt, err := s.readModels.GetEntityBySlug(ctx, req.Body.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attrsJSON := []byte("{}")
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
actorType, actor := actorInfo(ctx)
|
||||
created, err := s.relService.Create(ctx, app.CreateRelationshipCmd{
|
||||
SourceID: src.Entity.ID,
|
||||
TargetID: tgt.Entity.ID,
|
||||
SourceType: src.Entity.Type,
|
||||
TargetType: tgt.Entity.Type,
|
||||
Type: req.Body.Type,
|
||||
Attributes: derefAttrs(req.Body.Attributes),
|
||||
ActorType: actorType,
|
||||
Actor: actor,
|
||||
Method: "POST",
|
||||
Path: "/api/v1/relationships",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
VALUES ($1, $2, $3, $4, now())`,
|
||||
sourceID, targetID, req.Body.Type, attrsJSON)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||
return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists",
|
||||
domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel := gen.Relationship{
|
||||
Source: req.Body.Source,
|
||||
Target: req.Body.Target,
|
||||
Type: req.Body.Type,
|
||||
ValidFrom: time.Now(),
|
||||
Type: created.Type,
|
||||
ValidFrom: created.ValidFrom,
|
||||
}
|
||||
if req.Body.Attributes != nil {
|
||||
rel.Attributes = req.Body.Attributes
|
||||
}
|
||||
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
|
||||
nil, "POST", "/api/v1/relationships", "",
|
||||
nil,
|
||||
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gen.CreateRelationship201JSONResponse(rel), nil
|
||||
}
|
||||
|
||||
func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
|
||||
sourceID, err := s.resolveEntityID(ctx, req.Params.Source)
|
||||
src, err := s.readModels.GetEntityBySlug(ctx, req.Params.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := s.resolveEntityID(ctx, req.Params.Target)
|
||||
tgt, err := s.readModels.GetEntityBySlug(ctx, req.Params.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
|
||||
SourceID: sourceID,
|
||||
TargetID: targetID,
|
||||
Type: req.Params.RelType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == 0 {
|
||||
return nil, fmt.Errorf("%w: active relationship %s:%s:%s",
|
||||
domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target)
|
||||
}
|
||||
|
||||
actorType, actor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
|
||||
nil, "DELETE", "/api/v1/relationships", "",
|
||||
nil,
|
||||
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
if err := s.relService.End(ctx, src.Entity.ID, tgt.Entity.ID, req.Params.RelType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gen.EndRelationship204Response{}, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user