Problem: entity mutations (create/update/state) existed as three drifted
copies — HTTP CreateEntity/PatchEntity, MCP create_entity/
update_entity_attributes/set_entity_state — each with its own inline
SQL, its own validation subset (MCP validated lifecycle states, HTTP
did not; HTTP patched attributes without regenerating derived checks,
MCP did; MCP wrote no audit trail), the exact drift ADR 0016's first
vertical slice exists to collapse.
Change:
- internal/core/ports: DerivedCheck, Idempotency (adapter-owned request
hash + cached-body renderer so the replay record commits in the
create's transaction), IdempotentResponse + GetIdempotent read,
AuditEntry gains Method/Path/CorrelationID, Event gains
CorrelationID; EntityUpdateInput carries ExpectedVersion +
RederiveChecks (derivation for updates runs repo-side: the graph
host fallback reads relationships through the open tx).
- internal/adapters/postgres/repositories.go: EntityRepo (Create/
Update/SetState/reads/idempotency) preserving the load-bearing
check-then-act invariants in-tx: version WHERE-clause, declared
transitions + preconditions (ValidateTransition), duplicate-slug
mapping, audit/event/writeCheck all inside one BEGIN…COMMIT.
OntologyRepo: TTL-cached OntologyStore.
- internal/core/app/entities.go: EntityService — ontology validation
(type exists, concrete, state declared — the stricter MCP rule now
governs both surfaces), default-state resolution, id generation,
derivation for creates, audit/event construction, idempotency
pass-through.
- httpapi CreateEntity/PatchEntity rewired to the service; PATCH now
regenerates derived checks (the A2 parity gap). MCP create/update/
set-state tools call the same service — and now write audit + event
rows like the HTTP surface always did.
- Integration-test seed paths fixed for the adapters/postgres package
depth (../../../seeds).
Pre-existing failures documented: TestAPIEndToEnd (entity_types 60 vs
59; 501-endpoint now 200), TestClientLifecycleEndToEnd, TestPhase3*
rows — verified failing identically at ec11956 (scratch approval-
notifier commit test drift), unrelated to this change. All mutation
integration tests (create/patch/idempotency/audit/regeneration) pass.
Verification: make test-db (postgres + mcp green, httpapi green except
the pre-existing set), full non-DB suite (19 pkgs), golangci on new
packages — 0 issues.
179 lines
5.1 KiB
Go
179 lines
5.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"github.com/dtoro/oikos/internal/core/app"
|
|
"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) 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. The idempotency scope is the
|
|
// calling actor, so replays are per-caller.
|
|
actorType, actor := actorInfo(ctx)
|
|
var idem *ports.Idempotency
|
|
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
|
bodyJSON, _ := json.Marshal(req.Body)
|
|
hash := fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
|
key := *req.Params.IdempotencyKey
|
|
|
|
cached, err := s.entityRepo.GetIdempotent(ctx, actor, key)
|
|
if err == nil {
|
|
// Verify the request body hasn't changed.
|
|
if cached.RequestHash != hash {
|
|
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
|
|
}
|
|
// Replay the cached response.
|
|
if 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: cached.ResponseCode, Title: "replayed error"},
|
|
StatusCode: cached.ResponseCode,
|
|
}, nil
|
|
}
|
|
idem = &ports.Idempotency{
|
|
Actor: actor, Key: key, RequestHash: hash,
|
|
// RenderBody serializes the adapter's wire shape inside the
|
|
// create's transaction, so a replay returns the original
|
|
// response atomically with the insert.
|
|
RenderBody: func(e domain.Entity) []byte {
|
|
b, _ := json.Marshal(domainToGen(e))
|
|
return b
|
|
},
|
|
}
|
|
}
|
|
|
|
created, _, err := s.entities.Create(ctx, app.CreateEntityCmd{
|
|
Slug: req.Body.Slug,
|
|
Type: req.Body.Type,
|
|
Name: req.Body.Name,
|
|
State: derefStr(req.Body.State),
|
|
Attributes: derefAttrs(req.Body.Attributes),
|
|
ActorType: actorType,
|
|
Actor: actor,
|
|
Method: "POST",
|
|
Path: "/api/v1/entities",
|
|
Idempotency: idem,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entity := domainToGen(created)
|
|
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)
|
|
}
|
|
|
|
// Parse If-Match header (quoted version string).
|
|
ifMatch := trimQuotes(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)
|
|
}
|
|
|
|
patchActorType, patchActor := actorInfo(ctx)
|
|
updated, _, err := s.entities.Update(ctx, app.UpdateEntityCmd{
|
|
SlugOrID: req.Id,
|
|
ExpectedVer: expectedVersion,
|
|
Name: derefStr(req.Body.Name),
|
|
State: derefStr(req.Body.State),
|
|
Attributes: derefAttrs(req.Body.Attributes),
|
|
AttrsReplace: true,
|
|
Maintenance: req.Body.MaintenanceUntil,
|
|
SetMaint: req.Body.MaintenanceUntil != nil,
|
|
// Attribute changes propagate to derived checks (the A2 parity fix:
|
|
// previously only the MCP surface regenerated checks on attribute
|
|
// changes).
|
|
RederiveChecks: true,
|
|
ActorType: patchActorType,
|
|
Actor: patchActor,
|
|
Method: "PATCH",
|
|
Path: "/api/v1/entities/" + req.Id,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entity := domainToGen(updated)
|
|
s.entityCache.Invalidate(entity.Slug, entity.Id.String())
|
|
|
|
return gen.PatchEntity200JSONResponse{
|
|
Body: entity,
|
|
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
|
}, nil
|
|
}
|
|
|
|
func derefStr(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func derefAttrs(p *map[string]any) map[string]any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func trimQuotes(s string) string {
|
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// domainToGen converts a domain entity to the wire shape.
|
|
func domainToGen(e domain.Entity) gen.Entity {
|
|
id, _ := uuid.Parse(string(e.ID))
|
|
out := gen.Entity{
|
|
Id: id,
|
|
Slug: e.Slug,
|
|
Type: e.Type,
|
|
Name: e.Name,
|
|
Version: e.Version,
|
|
CreatedAt: e.CreatedAt,
|
|
UpdatedAt: e.UpdatedAt,
|
|
}
|
|
if e.State != "" {
|
|
out.State = &e.State
|
|
}
|
|
if e.MaintenanceUntil != nil {
|
|
out.MaintenanceUntil = e.MaintenanceUntil
|
|
}
|
|
if len(e.Attributes) > 0 {
|
|
attrs := e.Attributes
|
|
out.Attributes = &attrs
|
|
}
|
|
return out
|
|
}
|