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 }