From f87b777430f9519b9e391d7068e1cb873501872c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 11:16:20 +0000 Subject: [PATCH 1/5] feat: four-point hook SPI (incoming, pre-storage, post-commit, outgoing) Add pkg/hooks with a small Registry so callers can extend the server without forking. HTTP runs incoming after routing and outgoing before a resource envelope is written. Core runs pre-storage before persist and post-commit after commit (post-commit errors do not fail the write). Wire the same registry through runtime.Builder.WithHooks. Co-authored-by: Adegoke Adewoye --- pkg/core/bundle.go | 2 + pkg/core/conditional.go | 4 ++ pkg/core/doc.go | 2 + pkg/core/hooks_test.go | 120 ++++++++++++++++++++++++++++++++++++++++ pkg/core/service.go | 73 +++++++++++++++++++++++- pkg/hooks/doc.go | 18 ++++++ pkg/hooks/hooks.go | 108 ++++++++++++++++++++++++++++++++++++ pkg/hooks/hooks_test.go | 72 ++++++++++++++++++++++++ pkg/http/config.go | 6 ++ pkg/http/doc.go | 2 +- pkg/http/handler.go | 5 ++ pkg/http/hooks.go | 88 +++++++++++++++++++++++++++++ pkg/http/hooks_test.go | 76 +++++++++++++++++++++++++ pkg/http/writer.go | 33 ++++++++++- pkg/runtime/README.md | 1 + pkg/runtime/builder.go | 10 ++++ pkg/runtime/wire.go | 2 + 17 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 pkg/core/hooks_test.go create mode 100644 pkg/hooks/doc.go create mode 100644 pkg/hooks/hooks.go create mode 100644 pkg/hooks/hooks_test.go create mode 100644 pkg/http/hooks.go create mode 100644 pkg/http/hooks_test.go diff --git a/pkg/core/bundle.go b/pkg/core/bundle.go index 60e91807..a471cb4a 100644 --- a/pkg/core/bundle.go +++ b/pkg/core/bundle.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/store" "github.com/degoke/health-ai-stack/pkg/types" ) @@ -57,6 +58,7 @@ func (s *ResourceService) ProcessTransactionBundle(ctx context.Context, bundle * return nil, exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionTransaction, &types.ResourceEnvelope{ResourceType: "Bundle"}, nil) if err := s.syncDefinitionCatalog(ctx, writtenDefinitions, deletedDefinitions); err != nil { return nil, exceptionErr("sync definition catalog from transaction bundle", err) diff --git a/pkg/core/conditional.go b/pkg/core/conditional.go index a9355c78..ef3a9bed 100644 --- a/pkg/core/conditional.go +++ b/pkg/core/conditional.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/store" "github.com/degoke/health-ai-stack/pkg/types" ) @@ -61,6 +62,7 @@ func (s *ResourceService) UpdateIfMatch(ctx context.Context, resource *types.Res return nil, exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionUpdate, written, previous) return written, nil } @@ -97,6 +99,7 @@ func (s *ResourceService) DeleteIfMatch(ctx context.Context, resourceType, id, e return exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionDelete, current, current) return nil } @@ -154,5 +157,6 @@ func (s *ResourceService) PatchIfMatch(ctx context.Context, resourceType, id str return nil, exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionPatch, written, current) return written, nil } diff --git a/pkg/core/doc.go b/pkg/core/doc.go index bdaab93f..b9c99c7f 100644 --- a/pkg/core/doc.go +++ b/pkg/core/doc.go @@ -57,6 +57,8 @@ // - Indexer — search.Indexer invoked after resource/history persistence in session. // - Outbox — sync.Outbox; when non-nil, events are appended via // sync.WithWriteSession during each write (transactional through session EventStore). +// - Hooks — optional four-point intercept SPI; core runs pre-storage before persist +// and post-commit after a successful session commit. // // Methods: // diff --git a/pkg/core/hooks_test.go b/pkg/core/hooks_test.go new file mode 100644 index 00000000..cf6bbe6c --- /dev/null +++ b/pkg/core/hooks_test.go @@ -0,0 +1,120 @@ +package core_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/core" + "github.com/degoke/health-ai-stack/pkg/hooks" +) + +func TestPreStorageHookCanRejectAndMutate(t *testing.T) { + ctx := context.Background() + reg := hooks.NewRegistry() + if err := reg.On(hooks.PreStorage, func(_ context.Context, event *hooks.Event) error { + if event.Action != hooks.ActionCreate { + t.Fatalf("action = %q", event.Action) + } + if strings.Contains(string(event.Resource.JSON), "Blocked") { + return errors.New("blocked family") + } + event.Resource.JSON = []byte(`{"resourceType":"Patient","id":"pat-1","name":[{"family":"Hooked"}]}`) + return nil + }); err != nil { + t.Fatal(err) + } + + mem := newMemBackend() + svc, err := core.NewResourceService(core.ResourceServiceConfig{ + Resources: mem, + History: mem, + Sessions: mem, + IDPolicy: core.DefaultIDPolicy{}, + Hooks: reg, + }) + if err != nil { + t.Fatal(err) + } + + if _, err := svc.Create(ctx, patientEnvelope("pat-block", "Blocked")); err == nil { + t.Fatal("expected pre-storage rejection") + } + + created, err := svc.Create(ctx, patientEnvelope("pat-1", "Doe")) + if err != nil { + t.Fatalf("Create: %v", err) + } + if !strings.Contains(string(created.JSON), "Hooked") { + t.Fatalf("expected mutated JSON, got %s", created.JSON) + } +} + +func TestPostCommitHookSeesPersistedResource(t *testing.T) { + ctx := context.Background() + reg := hooks.NewRegistry() + var seen string + if err := reg.On(hooks.PostCommit, func(_ context.Context, event *hooks.Event) error { + seen = event.ID + return errors.New("post-commit must not fail the write") + }); err != nil { + t.Fatal(err) + } + + mem := newMemBackend() + svc, err := core.NewResourceService(core.ResourceServiceConfig{ + Resources: mem, + History: mem, + Sessions: mem, + IDPolicy: core.DefaultIDPolicy{}, + Hooks: reg, + }) + if err != nil { + t.Fatal(err) + } + created, err := svc.Create(ctx, patientEnvelope("pat-2", "Doe")) + if err != nil { + t.Fatalf("Create: %v", err) + } + if created.ID != "pat-2" { + t.Fatalf("id = %q", created.ID) + } + if seen != "pat-2" { + t.Fatalf("post-commit id = %q", seen) + } + if _, err := svc.Read(ctx, "Patient", "pat-2"); err != nil { + t.Fatalf("Read after post-commit error: %v", err) + } +} + +func TestPreStorageHookServiceErrorPreserved(t *testing.T) { + ctx := context.Background() + reg := hooks.NewRegistry() + if err := reg.On(hooks.PreStorage, func(context.Context, *hooks.Event) error { + return &core.ServiceError{Kind: core.ErrorKindNotSupported, Message: "writes disabled"} + }); err != nil { + t.Fatal(err) + } + mem := newMemBackend() + svc, err := core.NewResourceService(core.ResourceServiceConfig{ + Resources: mem, + History: mem, + Sessions: mem, + Hooks: reg, + }) + if err != nil { + t.Fatal(err) + } + _, err = svc.Create(ctx, patientEnvelope("pat-3", "Doe")) + if core.KindOf(err) != core.ErrorKindNotSupported { + t.Fatalf("expected not-supported, got %v kind %q", err, core.KindOf(err)) + } +} + +func TestNoHooksIsNoop(t *testing.T) { + harness := newTestHarness(t, harnessOptions{}) + if _, err := harness.svc.Create(context.Background(), patientEnvelope("pat-ok", "Doe")); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/core/service.go b/pkg/core/service.go index 90347587..ae47f356 100644 --- a/pkg/core/service.go +++ b/pkg/core/service.go @@ -3,10 +3,12 @@ package core import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/search" "github.com/degoke/health-ai-stack/pkg/store" hasync "github.com/degoke/health-ai-stack/pkg/sync" @@ -37,12 +39,13 @@ type ResourceService struct { terminologyCache terminology.Invalidator definitionIngestor DefinitionIngestor conformanceRefresh func(ctx context.Context) error + hooks hooks.Hooks } // ResourceServiceConfig configures a ResourceService. // // Resources, History, and Sessions are required. IDPolicy and Codec default when nil. -// Validator, Indexer, and Outbox are optional no-ops when nil. +// Validator, Indexer, Outbox, and Hooks are optional no-ops when nil. type ResourceServiceConfig struct { Resources store.ResourceStore History store.HistoryStore @@ -60,6 +63,7 @@ type ResourceServiceConfig struct { TerminologyCache terminology.Invalidator DefinitionIngestor DefinitionIngestor ConformanceRefresh func(ctx context.Context) error + Hooks hooks.Hooks } // NewResourceService constructs a ResourceService with required dependencies. @@ -98,6 +102,7 @@ func NewResourceService(cfg ResourceServiceConfig) (*ResourceService, error) { terminologyCache: cfg.TerminologyCache, definitionIngestor: cfg.DefinitionIngestor, conformanceRefresh: cfg.ConformanceRefresh, + hooks: cfg.Hooks, }, nil } @@ -168,6 +173,7 @@ func (s *ResourceService) Create(ctx context.Context, resource *types.ResourceEn return nil, exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionCreate, written, nil) if err := s.ingestDefinitionResource(ctx, written); err != nil { return written, exceptionErr("ingest definition into registry catalog", err) } @@ -250,6 +256,7 @@ func (s *ResourceService) Update(ctx context.Context, resource *types.ResourceEn return nil, exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionUpdate, written, previous) if err := s.ingestDefinitionResource(ctx, written); err != nil { return written, exceptionErr("ingest definition into registry catalog", err) } @@ -291,6 +298,7 @@ func (s *ResourceService) Delete(ctx context.Context, resourceType, id string) e return exceptionErr("commit write session", err) } committed = true + s.runPostCommit(ctx, hooks.ActionDelete, current, current) if err := s.removeDefinitionResource(ctx, current); err != nil { return exceptionErr("remove definition from registry catalog", err) } @@ -377,6 +385,10 @@ func (s *ResourceService) applyWriteExpectedVersion( versionID := uuid.NewString() now := time.Now().UTC() + envelope, err := s.runPreStorage(ctx, hooksAction(action), envelope, nil) + if err != nil { + return nil, err + } prepared, err := s.withVersionMeta(envelope, versionID, now) if err != nil { return nil, err @@ -448,6 +460,10 @@ func (s *ResourceService) applyDeleteExpectedVersion(ctx context.Context, sessio versionID := uuid.NewString() now := time.Now().UTC() + if _, err := s.runPreStorage(ctx, hooks.ActionDelete, current, current); err != nil { + return err + } + if expectedVersion != "" { conditional, ok := session.ResourceStore().(store.ConditionalResourceStore) if !ok { @@ -688,6 +704,61 @@ func cloneEnvelope(src *types.ResourceEnvelope) *types.ResourceEnvelope { return &out } +func hooksAction(action store.VersionAction) hooks.Action { + switch action { + case store.VersionActionCreate: + return hooks.ActionCreate + case store.VersionActionUpdate: + return hooks.ActionUpdate + case store.VersionActionDelete: + return hooks.ActionDelete + default: + return hooks.Action(action) + } +} + +func (s *ResourceService) runPreStorage(ctx context.Context, action hooks.Action, resource, previous *types.ResourceEnvelope) (*types.ResourceEnvelope, error) { + if s == nil || s.hooks == nil { + return resource, nil + } + event := &hooks.Event{ + Action: action, + Resource: resource, + Previous: previous, + } + if resource != nil { + event.ResourceType = resource.ResourceType + event.ID = resource.ID + } + if err := s.hooks.Run(ctx, hooks.PreStorage, event); err != nil { + var svcErr *ServiceError + if errors.As(err, &svcErr) { + return nil, err + } + return nil, invalidErr("pre-storage hook rejected write", err) + } + if event.Resource != nil { + return event.Resource, nil + } + return resource, nil +} + +func (s *ResourceService) runPostCommit(ctx context.Context, action hooks.Action, resource, previous *types.ResourceEnvelope) { + if s == nil || s.hooks == nil { + return + } + event := &hooks.Event{ + Action: action, + Resource: resource, + Previous: previous, + } + if resource != nil { + event.ResourceType = resource.ResourceType + event.ID = resource.ID + } + _ = s.hooks.Run(ctx, hooks.PostCommit, event) +} + func isStoreNotFound(err error) bool { if err == nil { return false diff --git a/pkg/hooks/doc.go b/pkg/hooks/doc.go new file mode 100644 index 00000000..0209bcda --- /dev/null +++ b/pkg/hooks/doc.go @@ -0,0 +1,18 @@ +// Package hooks is a small FHIR intercept SPI for Health AI Stack. +// +// HAPI's interceptor bus has dozens of pointcuts. This package keeps four: +// +// - Incoming — HTTP request after routing, before the handler runs +// - PreStorage — core write path, after validation/id assignment, before persist +// - PostCommit — core write path, after the write session commits +// - Outgoing — HTTP response, before a resource envelope is serialized +// +// Register Func values on a Registry with On, then wire the same Registry into +// core.ResourceServiceConfig.Hooks and http.Config.Hooks (runtime.Builder.WithHooks +// does both). Hooks run in registration order. Incoming, pre-storage, and outgoing +// errors abort the request; post-commit errors are ignored so a successful write +// is not reported as failure. +// +// Do not add more pointcuts here. Extend by registering another Func on one of +// these four, or by wrapping HTTP middleware for transport concerns. +package hooks diff --git a/pkg/hooks/hooks.go b/pkg/hooks/hooks.go new file mode 100644 index 00000000..78e907cc --- /dev/null +++ b/pkg/hooks/hooks.go @@ -0,0 +1,108 @@ +package hooks + +import ( + "context" + "fmt" + "sync" + + "github.com/degoke/health-ai-stack/pkg/types" +) + +// Point is one of the four intercept stages. +type Point string + +const ( + Incoming Point = "incoming" + PreStorage Point = "pre-storage" + PostCommit Point = "post-commit" + Outgoing Point = "outgoing" +) + +// Action names the FHIR interaction that triggered the hook. +type Action string + +const ( + ActionRead Action = "read" + ActionCreate Action = "create" + ActionUpdate Action = "update" + ActionPatch Action = "patch" + ActionDelete Action = "delete" + ActionSearch Action = "search" + ActionHistory Action = "history" + ActionTransaction Action = "transaction" + ActionOperation Action = "operation" + ActionMetadata Action = "metadata" +) + +// Event is the payload delivered to a hook. Resource is mutable on +// pre-storage and outgoing so a hook can replace or annotate it. +type Event struct { + Point Point + Action Action + ResourceType string + ID string + Operation string + Resource *types.ResourceEnvelope + Previous *types.ResourceEnvelope +} + +// Func is one intercept callback. +type Func func(ctx context.Context, event *Event) error + +// Hooks runs the four intercept stages. Registry implements it. +type Hooks interface { + Run(ctx context.Context, point Point, event *Event) error +} + +// Registry holds Func values for the four points. +type Registry struct { + mu sync.RWMutex + hooks map[Point][]Func +} + +// NewRegistry returns an empty hook registry. +func NewRegistry() *Registry { + return &Registry{hooks: make(map[Point][]Func)} +} + +// On registers fn for point. Unknown points are rejected. +func (r *Registry) On(point Point, fn Func) error { + if r == nil { + return fmt.Errorf("hooks: registry is nil") + } + if fn == nil { + return fmt.Errorf("hooks: func is nil") + } + switch point { + case Incoming, PreStorage, PostCommit, Outgoing: + default: + return fmt.Errorf("hooks: unknown point %q", point) + } + r.mu.Lock() + defer r.mu.Unlock() + if r.hooks == nil { + r.hooks = make(map[Point][]Func) + } + r.hooks[point] = append(r.hooks[point], fn) + return nil +} + +// Run invokes registered funcs for point in registration order. +func (r *Registry) Run(ctx context.Context, point Point, event *Event) error { + if r == nil { + return nil + } + if event == nil { + event = &Event{} + } + event.Point = point + r.mu.RLock() + fns := append([]Func(nil), r.hooks[point]...) + r.mu.RUnlock() + for _, fn := range fns { + if err := fn(ctx, event); err != nil { + return err + } + } + return nil +} diff --git a/pkg/hooks/hooks_test.go b/pkg/hooks/hooks_test.go new file mode 100644 index 00000000..b9f76d41 --- /dev/null +++ b/pkg/hooks/hooks_test.go @@ -0,0 +1,72 @@ +package hooks_test + +import ( + "context" + "errors" + "testing" + + "github.com/degoke/health-ai-stack/pkg/hooks" + "github.com/degoke/health-ai-stack/pkg/types" +) + +func TestRegistryRunsInOrderAndStopsOnError(t *testing.T) { + reg := hooks.NewRegistry() + var order []string + if err := reg.On(hooks.Incoming, func(context.Context, *hooks.Event) error { + order = append(order, "a") + return nil + }); err != nil { + t.Fatal(err) + } + if err := reg.On(hooks.Incoming, func(context.Context, *hooks.Event) error { + order = append(order, "b") + return errors.New("stop") + }); err != nil { + t.Fatal(err) + } + if err := reg.On(hooks.Incoming, func(context.Context, *hooks.Event) error { + order = append(order, "c") + return nil + }); err != nil { + t.Fatal(err) + } + + err := reg.Run(context.Background(), hooks.Incoming, &hooks.Event{Action: hooks.ActionRead, ResourceType: "Patient"}) + if err == nil || err.Error() != "stop" { + t.Fatalf("Run error = %v", err) + } + if len(order) != 2 || order[0] != "a" || order[1] != "b" { + t.Fatalf("order = %v", order) + } +} + +func TestRegistryRejectsUnknownPoint(t *testing.T) { + reg := hooks.NewRegistry() + if err := reg.On("storage-precommit", func(context.Context, *hooks.Event) error { return nil }); err == nil { + t.Fatal("expected unknown point error") + } +} + +func TestRegistryMutatesEventResource(t *testing.T) { + reg := hooks.NewRegistry() + if err := reg.On(hooks.PreStorage, func(_ context.Context, event *hooks.Event) error { + event.Resource = &types.ResourceEnvelope{ResourceType: "Patient", ID: "mutated"} + return nil + }); err != nil { + t.Fatal(err) + } + event := &hooks.Event{Resource: &types.ResourceEnvelope{ResourceType: "Patient", ID: "orig"}} + if err := reg.Run(context.Background(), hooks.PreStorage, event); err != nil { + t.Fatal(err) + } + if event.Resource.ID != "mutated" { + t.Fatalf("resource id = %q", event.Resource.ID) + } +} + +func TestNilRegistryRunIsNoop(t *testing.T) { + var reg *hooks.Registry + if err := reg.Run(context.Background(), hooks.Outgoing, &hooks.Event{}); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/http/config.go b/pkg/http/config.go index 3a773a0f..f651dc45 100644 --- a/pkg/http/config.go +++ b/pkg/http/config.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/degoke/health-ai-stack/pkg/auth" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/smart" "github.com/degoke/health-ai-stack/pkg/store" "github.com/degoke/health-ai-stack/pkg/types" @@ -152,6 +153,11 @@ type Config struct { // are configured. Use a distributed gateway limiter for multi-instance // deployments, or provide equivalent protection before this handler. RateLimit RateLimitConfig + + // Hooks is an optional four-point intercept SPI (incoming, pre-storage, + // post-commit, outgoing). HTTP runs incoming after routing and outgoing + // before a resource envelope is written. Core runs pre-storage and post-commit. + Hooks hooks.Hooks } // NewHandler constructs a FHIR REST http.Handler from Config. diff --git a/pkg/http/doc.go b/pkg/http/doc.go index 0dd4114d..ffe8ab30 100644 --- a/pkg/http/doc.go +++ b/pkg/http/doc.go @@ -57,7 +57,7 @@ // - NewHandler(Config) (net/http.Handler, error) — constructs the FHIR REST // handler tree. // - Config — BasePath (default /fhir), ResourceService, optional SearchService, -// CapabilitySource, ServerMetadata, Codec, auth hooks, and RateLimit. +// CapabilitySource, ServerMetadata, Codec, auth hooks, RateLimit, and Hooks. // - ServerMetadata — software name/version and server description for // CapabilityStatement generation. // - PrincipalResolver — extracts auth.Principal and auth.TenantContext from a diff --git a/pkg/http/handler.go b/pkg/http/handler.go index bce340bd..9fe81ca5 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -38,6 +38,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, unsupportedEndpoint(r.URL.Path)) return } + w = withHookContext(w, r.Context(), h.cfg.Hooks, route, r.Method) + if err := h.runIncoming(r.Context(), route, r.Method); err != nil { + writeError(w, err) + return + } switch route.kind { case routeMetadata: diff --git a/pkg/http/hooks.go b/pkg/http/hooks.go new file mode 100644 index 00000000..b3692832 --- /dev/null +++ b/pkg/http/hooks.go @@ -0,0 +1,88 @@ +package http + +import ( + "context" + "errors" + "net/http" + + "github.com/degoke/health-ai-stack/pkg/core" + "github.com/degoke/health-ai-stack/pkg/hooks" +) + +func (h *handler) runIncoming(ctx context.Context, route parsedRoute, method string) error { + if h == nil || h.cfg.Hooks == nil { + return nil + } + event := incomingEvent(route, method) + if err := h.cfg.Hooks.Run(ctx, hooks.Incoming, event); err != nil { + var svcErr *core.ServiceError + if errors.As(err, &svcErr) { + return err + } + return invalidRequest("incoming hook rejected request", err) + } + return nil +} + +func incomingEvent(route parsedRoute, method string) *hooks.Event { + return &hooks.Event{ + Action: actionFromRoute(route, method), + ResourceType: route.resourceType, + ID: route.id, + Operation: route.operation, + } +} + +func actionFromRoute(route parsedRoute, method string) hooks.Action { + switch route.kind { + case routeMetadata: + return hooks.ActionMetadata + case routeTransaction: + return hooks.ActionTransaction + case routeSystemSearch, routeTypeSearch: + return hooks.ActionSearch + case routeHistory: + return hooks.ActionHistory + case routeOperation, routeSystemOperation: + return hooks.ActionOperation + case routeType: + switch method { + case http.MethodGet: + return hooks.ActionSearch + case http.MethodPost: + return hooks.ActionCreate + case http.MethodPut: + return hooks.ActionUpdate + case http.MethodDelete: + return hooks.ActionDelete + default: + return hooks.Action(method) + } + case routeInstance: + switch method { + case http.MethodGet: + return hooks.ActionRead + case http.MethodPut: + return hooks.ActionUpdate + case http.MethodPatch: + return hooks.ActionPatch + case http.MethodDelete: + return hooks.ActionDelete + default: + return hooks.Action(method) + } + default: + return hooks.Action(method) + } +} + +func withHookContext(w http.ResponseWriter, ctx context.Context, runner hooks.Hooks, route parsedRoute, method string) http.ResponseWriter { + formatted, ok := w.(*formattedResponseWriter) + if !ok { + return w + } + formatted.hookCtx = ctx + formatted.hooks = runner + formatted.hookEvent = incomingEvent(route, method) + return formatted +} diff --git a/pkg/http/hooks_test.go b/pkg/http/hooks_test.go new file mode 100644 index 00000000..51092241 --- /dev/null +++ b/pkg/http/hooks_test.go @@ -0,0 +1,76 @@ +package http_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/degoke/health-ai-stack/pkg/hooks" + hahttp "github.com/degoke/health-ai-stack/pkg/http" + "github.com/degoke/health-ai-stack/pkg/types" +) + +func TestIncomingHookRejectsRequest(t *testing.T) { + reg := hooks.NewRegistry() + if err := reg.On(hooks.Incoming, func(_ context.Context, event *hooks.Event) error { + if event.Action == hooks.ActionRead && event.ResourceType == "Patient" { + return errors.New("no patients") + } + return nil + }); err != nil { + t.Fatal(err) + } + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{ + readFn: func(context.Context, string, string) (*types.ResourceEnvelope, error) { + t.Fatal("read should not run") + return nil, nil + }, + }, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodGet, "/fhir/Patient/pat-1", nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } +} + +func TestOutgoingHookMutatesResponse(t *testing.T) { + reg := hooks.NewRegistry() + if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { + if event.Resource == nil { + return errors.New("missing resource") + } + var payload map[string]any + if err := json.Unmarshal(event.Resource.JSON, &payload); err != nil { + return err + } + payload["active"] = true + data, err := json.Marshal(payload) + if err != nil { + return err + } + event.Resource.JSON = data + return nil + }); err != nil { + t.Fatal(err) + } + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{ + readFn: func(_ context.Context, _, id string) (*types.ResourceEnvelope, error) { + return patientEnvelope(id, "Doe"), nil + }, + }, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodGet, "/fhir/Patient/pat-1", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"active":true`) { + t.Fatalf("outgoing mutation missing: %s", rec.Body.String()) + } +} diff --git a/pkg/http/writer.go b/pkg/http/writer.go index 7db07262..738cd86f 100644 --- a/pkg/http/writer.go +++ b/pkg/http/writer.go @@ -1,15 +1,22 @@ package http import ( + "context" "encoding/json" + "errors" "net/http" + "github.com/degoke/health-ai-stack/pkg/core" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/types" ) type formattedResponseWriter struct { http.ResponseWriter - format responseFormat + format responseFormat + hookCtx context.Context + hooks hooks.Hooks + hookEvent *hooks.Event } func withResponseFormat(w http.ResponseWriter, format responseFormat) http.ResponseWriter { @@ -68,6 +75,30 @@ func writeEnvelope(w http.ResponseWriter, status int, envelope *types.ResourceEn writeError(w, invalidRequest("service returned no resource", nil)) return } + if formatted, ok := w.(*formattedResponseWriter); ok && formatted.hooks != nil { + event := &hooks.Event{} + if formatted.hookEvent != nil { + *event = *formatted.hookEvent + } + event.Resource = envelope + event.ResourceType = envelope.ResourceType + event.ID = envelope.ID + ctx := formatted.hookCtx + if ctx == nil { + ctx = context.Background() + } + if err := formatted.hooks.Run(ctx, hooks.Outgoing, event); err != nil { + var svcErr *core.ServiceError + if !errors.As(err, &svcErr) { + err = invalidRequest("outgoing hook rejected response", err) + } + writeError(w, err) + return + } + if event.Resource != nil { + envelope = event.Resource + } + } if headers == nil { headers = map[string]string{} } diff --git a/pkg/runtime/README.md b/pkg/runtime/README.md index 9d01412f..6e132a5d 100644 --- a/pkg/runtime/README.md +++ b/pkg/runtime/README.md @@ -142,6 +142,7 @@ Concrete provider implementations belong outside `pkg/runtime`. The adapter inte | `WithModules(paths...)` | Install local module directories at build time | | `WithHTTP(addr)` | Managed HTTP listen address (optional) | | `WithHTTPAuth(...)` / `WithHTTPMiddleware(...)` | Configure managed HTTP authentication and policy middleware | +| `WithHooks(...)` | Four-point intercept SPI (incoming, pre-storage, post-commit, outgoing) | | `WithHTTPRateLimit(config)` | Configure process-local managed HTTP rate limiting | | `WithModuleAuthorizer(authorizer)` | Authorize module installs and upgrades | | `WithModuleVerifier(verifier)` | Verify module signatures/content before install and upgrade | diff --git a/pkg/runtime/builder.go b/pkg/runtime/builder.go index 755bcbb7..c15fa046 100644 --- a/pkg/runtime/builder.go +++ b/pkg/runtime/builder.go @@ -7,6 +7,7 @@ import ( "github.com/degoke/health-ai-stack/pkg/conceptmap" "github.com/degoke/health-ai-stack/pkg/fhirpath" + "github.com/degoke/health-ai-stack/pkg/hooks" hahttp "github.com/degoke/health-ai-stack/pkg/http" "github.com/degoke/health-ai-stack/pkg/modules" "github.com/degoke/health-ai-stack/pkg/packages" @@ -42,6 +43,7 @@ type Builder struct { httpPrincipalResolver hahttp.PrincipalResolver httpAuthChecker hahttp.AuthChecker httpRateLimit hahttp.RateLimitConfig + hooks hooks.Hooks moduleAuthorizer modules.InstallAuthorizer moduleVerifier modules.ModuleVerifier @@ -237,6 +239,14 @@ func (b *Builder) WithHTTPAuth(resolver hahttp.PrincipalResolver, checker hahttp return b } +// WithHooks registers the four-point intercept SPI on both core writes and +// the FHIR HTTP handler. The same registry should be used for incoming, +// pre-storage, post-commit, and outgoing callbacks. +func (b *Builder) WithHooks(h hooks.Hooks) *Builder { + b.hooks = h + return b +} + // WithHTTPRateLimit enables process-local request limiting for the managed // FHIR handler. func (b *Builder) WithHTTPRateLimit(config hahttp.RateLimitConfig) *Builder { diff --git a/pkg/runtime/wire.go b/pkg/runtime/wire.go index a52fb0d5..e8f1f2d4 100644 --- a/pkg/runtime/wire.go +++ b/pkg/runtime/wire.go @@ -464,6 +464,7 @@ func (b *Builder) wireCommon(ctx context.Context, state *wireState, pc persisten GlobalTerminologyScope: terminology.GlobalScopeID, TerminologyCache: terminologyCache, DefinitionIngestor: regManager, + Hooks: b.hooks, ConformanceRefresh: func(ctx context.Context) error { snap, err := conformanceRuntime.Refresh(ctx) if err != nil { @@ -746,6 +747,7 @@ func (b *Builder) wireCommon(ctx context.Context, state *wireState, pc persisten SQLQueryService: state.services.SQLQueryService, ViewExportService: state.services.ViewExportService, RateLimit: b.httpRateLimit, + Hooks: b.hooks, ServerMetadata: hahttp.ServerMetadata{ SoftwareName: "haistack-runtime", SoftwareVersion: "1.0.0", From 5cab37fce4e8fe0ec89e0e5f0d91181428cf038c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 10:18:30 +0000 Subject: [PATCH 2/5] fix: keep ActionPatch, outgoing search/history, and write-hook identity Patch no longer delegates to Update, so pre-storage and post-commit see ActionPatch while persist stays VersionActionUpdate. Search and history bundles run outgoing hooks, transaction post-commit gets the real response bundle, update/patch pass Previous, and identity mutations after pre-storage are rejected. Co-authored-by: Adegoke Adewoye --- pkg/core/bundle.go | 39 +++++---- pkg/core/conditional.go | 53 +----------- pkg/core/hooks_test.go | 174 ++++++++++++++++++++++++++++++++++++++++ pkg/core/patch.go | 58 +++++++++++--- pkg/core/service.go | 83 +++++++++++++++++-- pkg/http/handler.go | 4 +- pkg/http/hooks_test.go | 93 +++++++++++++++++++++ pkg/http/writer.go | 7 ++ 8 files changed, 423 insertions(+), 88 deletions(-) diff --git a/pkg/core/bundle.go b/pkg/core/bundle.go index a471cb4a..c27fe719 100644 --- a/pkg/core/bundle.go +++ b/pkg/core/bundle.go @@ -58,22 +58,23 @@ func (s *ResourceService) ProcessTransactionBundle(ctx context.Context, bundle * return nil, exceptionErr("commit write session", err) } committed = true - s.runPostCommit(ctx, hooks.ActionTransaction, &types.ResourceEnvelope{ResourceType: "Bundle"}, nil) - - if err := s.syncDefinitionCatalog(ctx, writtenDefinitions, deletedDefinitions); err != nil { - return nil, exceptionErr("sync definition catalog from transaction bundle", err) - } responseJSON, err := buildTransactionResponseBundle(responseEntries) if err != nil { return nil, exceptionErr("build transaction response bundle", err) } - - return &types.ResourceEnvelope{ + response := &types.ResourceEnvelope{ ResourceType: "Bundle", JSON: responseJSON, Hash: mustHash(responseJSON), - }, nil + } + s.runPostCommit(ctx, hooks.ActionTransaction, response, nil) + + if err := s.syncDefinitionCatalog(ctx, writtenDefinitions, deletedDefinitions); err != nil { + return nil, exceptionErr("sync definition catalog from transaction bundle", err) + } + + return response, nil } type transactionBundle struct { @@ -263,7 +264,7 @@ func (s *ResourceService) executeBundleCreate( return bundleExecutionResult{}, conflictErr(fmt.Sprintf("resource already exists: %s/%s", envelope.ResourceType, id), nil) } - written, err := s.applyWrite(ctx, session, envelope, store.VersionActionCreate) + written, err := s.applyWrite(ctx, session, envelope, store.VersionActionCreate, nil) if err != nil { return bundleExecutionResult{}, err } @@ -309,33 +310,29 @@ func (s *ResourceService) executeBundleUpdate( } } - exists, err := session.ResourceStore().Exists(ctx, resourceType, id) + previous, err := session.ResourceStore().Read(ctx, resourceType, id) if err != nil { - return bundleExecutionResult{}, exceptionErr("check resource existence", err) - } - if !exists { - return bundleExecutionResult{}, notFoundErr(fmt.Sprintf("resource not found: %s/%s", resourceType, id), nil) + if isStoreNotFound(err) { + return bundleExecutionResult{}, notFoundErr(fmt.Sprintf("resource not found: %s/%s", resourceType, id), err) + } + return bundleExecutionResult{}, exceptionErr("read previous resource", err) } if entry.IfMatch != "" { expected, ok := versionFromETag(entry.IfMatch) if !ok { return bundleExecutionResult{}, invalidErr("bundle ifMatch must contain one entity tag", nil) } - current, err := session.ResourceStore().Read(ctx, resourceType, id) - if err != nil { - return bundleExecutionResult{}, exceptionErr("read current resource for ifMatch", err) - } - if expected != "*" && expected != current.VersionID { + if expected != "*" && expected != previous.VersionID { return bundleExecutionResult{}, preconditionErr(fmt.Sprintf("resource version does not match expected version %q", expected), nil) } - written, err := s.applyWriteExpectedVersion(ctx, session, envelope, store.VersionActionUpdate, expected) + written, err := s.applyWriteExpectedVersion(ctx, session, envelope, store.VersionActionUpdate, expected, hooks.ActionUpdate, previous) if err != nil { return bundleExecutionResult{}, err } return bundleExecutionFromWrite("200 OK", written), nil } - written, err := s.applyWrite(ctx, session, envelope, store.VersionActionUpdate) + written, err := s.applyWrite(ctx, session, envelope, store.VersionActionUpdate, previous) if err != nil { return bundleExecutionResult{}, err } diff --git a/pkg/core/conditional.go b/pkg/core/conditional.go index ef3a9bed..6d47afb8 100644 --- a/pkg/core/conditional.go +++ b/pkg/core/conditional.go @@ -51,7 +51,7 @@ func (s *ResourceService) UpdateIfMatch(ctx context.Context, resource *types.Res } return nil, exceptionErr("read previous resource", err) } - written, err := s.applyWriteExpectedVersion(ctx, session, envelope, store.VersionActionUpdate, expectedVersion) + written, err := s.applyWriteExpectedVersion(ctx, session, envelope, store.VersionActionUpdate, expectedVersion, hooks.ActionUpdate, previous) if err != nil { return nil, err } @@ -109,54 +109,5 @@ func (s *ResourceService) PatchIfMatch(ctx context.Context, resourceType, id str if resourceType == "" || id == "" || expectedVersion == "" { return nil, invalidErr("resourceType, id, and expected version are required", nil) } - if len(patchJSON) == 0 { - return nil, invalidErr("patch body is required", nil) - } - session, err := s.sessions.BeginWrite(ctx) - if err != nil { - return nil, exceptionErr("begin write session", err) - } - committed := false - defer func() { - if !committed { - _ = session.Rollback(ctx) - } - }() - current, err := session.ResourceStore().Read(ctx, resourceType, id) - if err != nil { - if isStoreNotFound(err) { - return nil, notFoundErr(fmt.Sprintf("resource not found: %s/%s", resourceType, id), err) - } - return nil, exceptionErr("read resource for patch", err) - } - patchedJSON, err := applyJSONPatch(current.JSON, patchJSON) - if err != nil { - return nil, invalidErr("apply JSON Patch", err) - } - if err := validatePatchedIdentity(patchedJSON, resourceType, id); err != nil { - return nil, err - } - envelope := &types.ResourceEnvelope{ResourceType: resourceType, ID: id, JSON: patchedJSON} - envelope, err = s.normalizeEnvelope(envelope) - if err != nil { - return nil, err - } - if s.validator != nil { - if err := s.validator.ValidateResource(ctx, envelope); err != nil { - return nil, invalidErr("resource validation failed", err) - } - } - written, err := s.applyWriteExpectedVersion(ctx, session, envelope, store.VersionActionUpdate, expectedVersion) - if err != nil { - return nil, err - } - if err := s.removePreviousTerminology(ctx, session, current, written); err != nil { - return nil, exceptionErr("replace previous terminology projection", err) - } - if err := session.Commit(ctx); err != nil { - return nil, exceptionErr("commit write session", err) - } - committed = true - s.runPostCommit(ctx, hooks.ActionPatch, written, current) - return written, nil + return s.patchAndCommit(ctx, resourceType, id, patchJSON, expectedVersion) } diff --git a/pkg/core/hooks_test.go b/pkg/core/hooks_test.go index cf6bbe6c..9ed12a6f 100644 --- a/pkg/core/hooks_test.go +++ b/pkg/core/hooks_test.go @@ -8,6 +8,7 @@ import ( "github.com/degoke/health-ai-stack/pkg/core" "github.com/degoke/health-ai-stack/pkg/hooks" + "github.com/degoke/health-ai-stack/pkg/types" ) func TestPreStorageHookCanRejectAndMutate(t *testing.T) { @@ -118,3 +119,176 @@ func TestNoHooksIsNoop(t *testing.T) { t.Fatal(err) } } + +func TestPatchHooksUseActionPatch(t *testing.T) { + ctx := context.Background() + var pre, post hooks.Action + var prePrevious, postPrevious bool + reg := hooks.NewRegistry() + if err := reg.On(hooks.PreStorage, func(_ context.Context, event *hooks.Event) error { + if event.Action == hooks.ActionCreate { + return nil + } + pre = event.Action + prePrevious = event.Previous != nil + return nil + }); err != nil { + t.Fatal(err) + } + if err := reg.On(hooks.PostCommit, func(_ context.Context, event *hooks.Event) error { + if event.Action == hooks.ActionCreate { + return nil + } + post = event.Action + postPrevious = event.Previous != nil + return nil + }); err != nil { + t.Fatal(err) + } + + svc := newHookedService(t, reg) + if _, err := svc.Create(ctx, patientEnvelope("pat-1", "Doe")); err != nil { + t.Fatalf("Create: %v", err) + } + patch := []byte(`[{"op":"replace","path":"/name/0/family","value":"Smith"}]`) + if _, err := svc.Patch(ctx, "Patient", "pat-1", patch); err != nil { + t.Fatalf("Patch: %v", err) + } + if pre != hooks.ActionPatch { + t.Fatalf("pre-storage action = %q, want patch", pre) + } + if post != hooks.ActionPatch { + t.Fatalf("post-commit action = %q, want patch", post) + } + if !prePrevious { + t.Fatal("expected non-nil Previous on patch pre-storage") + } + if !postPrevious { + t.Fatal("expected non-nil Previous on patch post-commit") + } +} + +func TestUpdatePreStorageReceivesPrevious(t *testing.T) { + ctx := context.Background() + var sawPrevious bool + var previousFamily string + reg := hooks.NewRegistry() + if err := reg.On(hooks.PreStorage, func(_ context.Context, event *hooks.Event) error { + if event.Action != hooks.ActionUpdate { + return nil + } + if event.Previous == nil { + t.Fatal("Previous was nil on update pre-storage") + } + sawPrevious = true + if event.Previous.ID != "pat-1" { + t.Fatalf("Previous.ID = %q", event.Previous.ID) + } + previousFamily = string(event.Previous.JSON) + return nil + }); err != nil { + t.Fatal(err) + } + + svc := newHookedService(t, reg) + if _, err := svc.Create(ctx, patientEnvelope("pat-1", "Doe")); err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := svc.Update(ctx, patientEnvelope("pat-1", "Smith")); err != nil { + t.Fatalf("Update: %v", err) + } + if !sawPrevious { + t.Fatal("update pre-storage did not run") + } + if !strings.Contains(previousFamily, "Doe") { + t.Fatalf("Previous JSON = %s, want original family Doe", previousFamily) + } +} + +func TestPreStorageIdentityMutationRejected(t *testing.T) { + ctx := context.Background() + reg := hooks.NewRegistry() + if err := reg.On(hooks.PreStorage, func(_ context.Context, event *hooks.Event) error { + if event.Action != hooks.ActionUpdate { + return nil + } + event.Resource.ID = "hijacked" + event.Resource.JSON = []byte(`{"resourceType":"Patient","id":"hijacked","name":[{"family":"X"}]}`) + return nil + }); err != nil { + t.Fatal(err) + } + + svc := newHookedService(t, reg) + if _, err := svc.Create(ctx, patientEnvelope("pat-1", "Doe")); err != nil { + t.Fatalf("Create: %v", err) + } + _, err := svc.Update(ctx, patientEnvelope("pat-1", "Smith")) + if err == nil || core.KindOf(err) != core.ErrorKindInvalid { + t.Fatalf("expected invalid identity mutation, got %v kind %q", err, core.KindOf(err)) + } + if _, err := svc.Read(ctx, "Patient", "pat-1"); err != nil { + t.Fatalf("original resource missing after rejected mutation: %v", err) + } + if _, err := svc.Read(ctx, "Patient", "hijacked"); !core.IsNotFound(err) { + t.Fatalf("hijacked id should not exist, got %v", err) + } +} + +func TestTransactionPostCommitSeesBundleJSON(t *testing.T) { + ctx := context.Background() + var postJSON string + var postType string + reg := hooks.NewRegistry() + if err := reg.On(hooks.PostCommit, func(_ context.Context, event *hooks.Event) error { + if event.Action != hooks.ActionTransaction { + return nil + } + if event.Resource == nil { + t.Fatal("transaction post-commit resource was nil") + } + postType = event.Resource.ResourceType + postJSON = string(event.Resource.JSON) + return nil + }); err != nil { + t.Fatal(err) + } + + svc := newHookedService(t, reg) + bundle := []byte(`{"resourceType":"Bundle","type":"transaction","entry":[{"request":{"method":"POST","url":"Patient"},"resource":{"resourceType":"Patient","name":[{"family":"Txn"}]}}]}`) + resp, err := svc.ProcessTransactionBundle(ctx, &types.ResourceEnvelope{ResourceType: "Bundle", JSON: bundle}) + if err != nil { + t.Fatalf("ProcessTransactionBundle: %v", err) + } + if postType != "Bundle" { + t.Fatalf("post-commit resourceType = %q", postType) + } + if postJSON == "" { + t.Fatal("expected transaction post-commit JSON") + } + if !strings.Contains(postJSON, "transaction-response") && !strings.Contains(postJSON, `"entry"`) { + t.Fatalf("post-commit JSON missing bundle payload: %s", postJSON) + } + if resp == nil || len(resp.JSON) == 0 { + t.Fatal("expected transaction response JSON") + } + if postJSON != string(resp.JSON) { + t.Fatalf("post-commit JSON does not match response envelope") + } +} + +func newHookedService(t *testing.T, reg *hooks.Registry) *core.ResourceService { + t.Helper() + mem := newMemBackend() + svc, err := core.NewResourceService(core.ResourceServiceConfig{ + Resources: mem, + History: mem, + Sessions: mem, + IDPolicy: core.DefaultIDPolicy{}, + Hooks: reg, + }) + if err != nil { + t.Fatal(err) + } + return svc +} diff --git a/pkg/core/patch.go b/pkg/core/patch.go index 1b24fcb8..9de05385 100644 --- a/pkg/core/patch.go +++ b/pkg/core/patch.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" + "github.com/degoke/health-ai-stack/pkg/hooks" + "github.com/degoke/health-ai-stack/pkg/store" "github.com/degoke/health-ai-stack/pkg/types" ) @@ -53,18 +55,37 @@ func (o *jsonPatchOp) UnmarshalJSON(data []byte) error { // Patch applies a JSON Patch (RFC 6902) to an existing resource. func (s *ResourceService) Patch(ctx context.Context, resourceType, id string, patchJSON []byte) (*types.ResourceEnvelope, error) { + return s.patchAndCommit(ctx, resourceType, id, patchJSON, "") +} + +func (s *ResourceService) patchAndCommit(ctx context.Context, resourceType, id string, patchJSON []byte, expectedVersion string) (*types.ResourceEnvelope, error) { if resourceType == "" || id == "" { return nil, invalidErr("resourceType and id are required", nil) } if len(patchJSON) == 0 { return nil, invalidErr("patch body is required", nil) } + if err := s.idPolicy.Validate(resourceType, id); err != nil { + return nil, invalidErr("invalid resource id", err, "Resource.id") + } - current, err := s.Read(ctx, resourceType, id) + session, err := s.sessions.BeginWrite(ctx) if err != nil { - return nil, err + return nil, exceptionErr("begin write session", err) + } + committed := false + defer func() { + if !committed { + _ = session.Rollback(ctx) + } + }() + current, err := session.ResourceStore().Read(ctx, resourceType, id) + if err != nil { + if isStoreNotFound(err) { + return nil, notFoundErr(fmt.Sprintf("resource not found: %s/%s", resourceType, id), err) + } + return nil, exceptionErr("read resource for patch", err) } - patchedJSON, err := applyJSONPatch(current.JSON, patchJSON) if err != nil { return nil, invalidErr("apply JSON Patch", err) @@ -72,13 +93,32 @@ func (s *ResourceService) Patch(ctx context.Context, resourceType, id string, pa if err := validatePatchedIdentity(patchedJSON, resourceType, id); err != nil { return nil, err } - - envelope := &types.ResourceEnvelope{ - ResourceType: resourceType, - ID: id, - JSON: patchedJSON, + envelope := &types.ResourceEnvelope{ResourceType: resourceType, ID: id, JSON: patchedJSON} + envelope, err = s.normalizeEnvelope(envelope) + if err != nil { + return nil, err + } + if s.validator != nil { + if err := s.validator.ValidateResource(ctx, envelope); err != nil { + return nil, invalidErr("resource validation failed", err) + } + } + written, err := s.applyWriteExpectedVersion(ctx, session, envelope, store.VersionActionUpdate, expectedVersion, hooks.ActionPatch, current) + if err != nil { + return nil, err + } + if err := s.removePreviousTerminology(ctx, session, current, written); err != nil { + return nil, exceptionErr("replace previous terminology projection", err) + } + if err := session.Commit(ctx); err != nil { + return nil, exceptionErr("commit write session", err) + } + committed = true + s.runPostCommit(ctx, hooks.ActionPatch, written, current) + if err := s.ingestDefinitionResource(ctx, written); err != nil { + return written, exceptionErr("ingest definition into registry catalog", err) } - return s.Update(ctx, envelope) + return written, nil } func validatePatchedIdentity(data []byte, resourceType, id string) error { diff --git a/pkg/core/service.go b/pkg/core/service.go index ae47f356..6585623f 100644 --- a/pkg/core/service.go +++ b/pkg/core/service.go @@ -165,7 +165,7 @@ func (s *ResourceService) Create(ctx context.Context, resource *types.ResourceEn return nil, conflictErr(fmt.Sprintf("resource already exists: %s/%s", envelope.ResourceType, id), nil) } - written, err := s.applyWrite(ctx, session, envelope, store.VersionActionCreate) + written, err := s.applyWrite(ctx, session, envelope, store.VersionActionCreate, nil) if err != nil { return nil, err } @@ -245,7 +245,7 @@ func (s *ResourceService) Update(ctx context.Context, resource *types.ResourceEn return nil, exceptionErr("read previous resource", err) } - written, err := s.applyWrite(ctx, session, envelope, store.VersionActionUpdate) + written, err := s.applyWrite(ctx, session, envelope, store.VersionActionUpdate, previous) if err != nil { return nil, err } @@ -368,27 +368,43 @@ func (s *ResourceService) applyWrite( session store.WriteSession, envelope *types.ResourceEnvelope, action store.VersionAction, + previous *types.ResourceEnvelope, ) (*types.ResourceEnvelope, error) { - return s.applyWriteExpectedVersion(ctx, session, envelope, action, "") + return s.applyWriteExpectedVersion(ctx, session, envelope, action, "", hooksAction(action), previous) } // applyWriteExpectedVersion performs the version comparison in the same write // session as the mutation. An empty expected version selects ordinary writes; // a non-empty value requires a ConditionalResourceStore implementation. +// persist action is the store VersionAction (create/update); hookAction is the +// FHIR interaction seen by pre-storage (for example ActionPatch while persist +// remains VersionActionUpdate). func (s *ResourceService) applyWriteExpectedVersion( ctx context.Context, session store.WriteSession, envelope *types.ResourceEnvelope, action store.VersionAction, expectedVersion string, + hookAction hooks.Action, + previous *types.ResourceEnvelope, ) (*types.ResourceEnvelope, error) { versionID := uuid.NewString() now := time.Now().UTC() - envelope, err := s.runPreStorage(ctx, hooksAction(action), envelope, nil) + if hookAction == "" { + hookAction = hooksAction(action) + } + originalType, originalID := "", "" + if envelope != nil { + originalType, originalID = envelope.ResourceType, envelope.ID + } + envelope, err := s.runPreStorage(ctx, hookAction, envelope, previous) if err != nil { return nil, err } + if err := rejectIdentityMutation(originalType, originalID, envelope); err != nil { + return nil, err + } prepared, err := s.withVersionMeta(envelope, versionID, now) if err != nil { return nil, err @@ -460,7 +476,15 @@ func (s *ResourceService) applyDeleteExpectedVersion(ctx context.Context, sessio versionID := uuid.NewString() now := time.Now().UTC() - if _, err := s.runPreStorage(ctx, hooks.ActionDelete, current, current); err != nil { + originalType, originalID := "", "" + if current != nil { + originalType, originalID = current.ResourceType, current.ID + } + mutated, err := s.runPreStorage(ctx, hooks.ActionDelete, current, current) + if err != nil { + return err + } + if err := rejectIdentityMutation(originalType, originalID, mutated); err != nil { return err } @@ -743,6 +767,55 @@ func (s *ResourceService) runPreStorage(ctx context.Context, action hooks.Action return resource, nil } +func rejectIdentityMutation(originalType, originalID string, env *types.ResourceEnvelope) error { + if env == nil { + return invalidErr("pre-storage hook removed the resource", nil) + } + if env.ResourceType != originalType { + return invalidErr( + fmt.Sprintf("pre-storage hook cannot change resourceType from %q to %q", originalType, env.ResourceType), + nil, + "Resource.resourceType", + ) + } + if env.ID != originalID { + return invalidErr( + fmt.Sprintf("pre-storage hook cannot change id from %q to %q", originalID, env.ID), + nil, + "Resource.id", + ) + } + if len(env.JSON) == 0 { + return nil + } + actualType, err := types.GetResourceType(env.JSON) + if err != nil { + return invalidErr("pre-storage hook produced invalid resourceType", err, "Resource.resourceType") + } + if actualType != originalType { + return invalidErr( + fmt.Sprintf("pre-storage hook cannot change resourceType from %q to %q", originalType, actualType), + nil, + "Resource.resourceType", + ) + } + if originalID == "" { + return nil + } + actualID, err := types.GetID(env.JSON) + if err != nil { + return invalidErr("pre-storage hook produced invalid id", err, "Resource.id") + } + if actualID != originalID { + return invalidErr( + fmt.Sprintf("pre-storage hook cannot change id from %q to %q", originalID, actualID), + nil, + "Resource.id", + ) + } + return nil +} + func (s *ResourceService) runPostCommit(ctx context.Context, action hooks.Action, resource, previous *types.ResourceEnvelope) { if s == nil || s.hooks == nil { return diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 9fe81ca5..322a64a8 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -609,7 +609,7 @@ func (h *handler) handleHistory(w http.ResponseWriter, r *http.Request, resource writeError(w, invalidRequest("build history bundle", err)) return } - writeResource(w, http.StatusOK, data, nil) + writeBundleJSON(w, http.StatusOK, data) } func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request, resourceType string) { @@ -675,7 +675,7 @@ func (h *handler) handleSearchWithParams(w http.ResponseWriter, r *http.Request, writeError(w, invalidRequest("build searchset bundle", err)) return } - writeResource(w, http.StatusOK, data, nil) + writeBundleJSON(w, http.StatusOK, data) } func searchQueryParams(params url.Values) url.Values { diff --git a/pkg/http/hooks_test.go b/pkg/http/hooks_test.go index 51092241..6e6d7a94 100644 --- a/pkg/http/hooks_test.go +++ b/pkg/http/hooks_test.go @@ -5,11 +5,14 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "strings" "testing" "github.com/degoke/health-ai-stack/pkg/hooks" hahttp "github.com/degoke/health-ai-stack/pkg/http" + "github.com/degoke/health-ai-stack/pkg/search" + "github.com/degoke/health-ai-stack/pkg/store" "github.com/degoke/health-ai-stack/pkg/types" ) @@ -74,3 +77,93 @@ func TestOutgoingHookMutatesResponse(t *testing.T) { t.Fatalf("outgoing mutation missing: %s", rec.Body.String()) } } + +func TestOutgoingHookRunsOnSearchBundle(t *testing.T) { + reg := hooks.NewRegistry() + var seenAction hooks.Action + var seenType string + if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { + seenAction = event.Action + if event.Resource == nil { + return errors.New("missing search bundle") + } + seenType = event.Resource.ResourceType + event.Resource.JSON = []byte(strings.ReplaceAll(string(event.Resource.JSON), `"Doe"`, `"REDACTED"`)) + return nil + }); err != nil { + t.Fatal(err) + } + total := 1 + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{}, + SearchService: &fakeSearchService{ + searchFn: func(_ context.Context, resourceType string, _ url.Values) (*search.SearchBundle, error) { + return &search.SearchBundle{ + ResourceType: resourceType, + Total: &total, + Entries: []search.BundleEntry{{ + FullURL: "Patient/pat-1", + Resource: patientEnvelope("pat-1", "Doe"), + Mode: "match", + }}, + }, nil + }, + }, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodGet, "/fhir/Patient?family=Doe", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if seenAction != hooks.ActionSearch { + t.Fatalf("outgoing action = %q, want search", seenAction) + } + if seenType != "Bundle" { + t.Fatalf("outgoing resourceType = %q, want Bundle", seenType) + } + if !strings.Contains(rec.Body.String(), "REDACTED") { + t.Fatalf("expected PHI-stripped search bundle, got %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"Doe"`) { + t.Fatalf("original family still present: %s", rec.Body.String()) + } +} + +func TestOutgoingHookRunsOnHistoryBundle(t *testing.T) { + reg := hooks.NewRegistry() + var seenAction hooks.Action + if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { + seenAction = event.Action + if event.Resource == nil || event.Resource.ResourceType != "Bundle" { + return errors.New("expected history bundle envelope") + } + event.Resource.JSON = []byte(strings.ReplaceAll(string(event.Resource.JSON), `"Doe"`, `"REDACTED"`)) + return nil + }); err != nil { + t.Fatal(err) + } + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{ + historyFn: func(_ context.Context, resourceType, id string) ([]store.ResourceVersion, error) { + return []store.ResourceVersion{{ + ResourceType: resourceType, + ID: id, + VersionID: "1", + Action: store.VersionActionCreate, + Resource: patientEnvelope(id, "Doe"), + }}, nil + }, + }, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodGet, "/fhir/Patient/pat-1/_history", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if seenAction != hooks.ActionHistory { + t.Fatalf("outgoing action = %q, want history", seenAction) + } + if !strings.Contains(rec.Body.String(), "REDACTED") { + t.Fatalf("expected PHI-stripped history bundle, got %s", rec.Body.String()) + } +} diff --git a/pkg/http/writer.go b/pkg/http/writer.go index 738cd86f..64a1849c 100644 --- a/pkg/http/writer.go +++ b/pkg/http/writer.go @@ -70,6 +70,13 @@ func writeResource(w http.ResponseWriter, status int, data []byte, headers map[s _, _ = w.Write(data) } +func writeBundleJSON(w http.ResponseWriter, status int, data []byte) { + writeEnvelope(w, status, &types.ResourceEnvelope{ + ResourceType: "Bundle", + JSON: data, + }, nil) +} + func writeEnvelope(w http.ResponseWriter, status int, envelope *types.ResourceEnvelope, headers map[string]string) { if envelope == nil { writeError(w, invalidRequest("service returned no resource", nil)) From c7abdae146c3bebda3f34a2f85160fa63929bef8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 10:40:18 +0000 Subject: [PATCH 3/5] fix: preserve search ResourceType, PATCH auth, and metadata outgoing Keep the incoming Event.ResourceType on outgoing search/history so PHI hooks still see the queried type while Resource is the Bundle. Authorize HTTP PATCH as "patch" (SMART still maps to OpUpdate). Route /metadata through writeEnvelope so outgoing hooks can redact CapabilityStatement. Co-authored-by: Adegoke Adewoye --- pkg/auth/request.go | 2 +- pkg/http/handler.go | 9 ++++--- pkg/http/hooks_test.go | 59 +++++++++++++++++++++++++++++++++++++++--- pkg/http/http_test.go | 25 ++++++++++++++++++ pkg/http/writer.go | 8 ++++-- 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/pkg/auth/request.go b/pkg/auth/request.go index 1e423c76..743e2355 100644 --- a/pkg/auth/request.go +++ b/pkg/auth/request.go @@ -13,7 +13,7 @@ type ReadRequest struct { type WriteRequest struct { Principal Principal Tenant TenantContext - Operation string // create | update + Operation string // create | update | patch ResourceType string ID string RequiredPermissions []string diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 322a64a8..f1f57132 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -345,7 +345,10 @@ func (h *handler) handleMetadata(w http.ResponseWriter, r *http.Request) { writeError(w, invalidRequest("build CapabilityStatement", err)) return } - writeResource(w, http.StatusOK, data, nil) + writeEnvelope(w, http.StatusOK, &types.ResourceEnvelope{ + ResourceType: "CapabilityStatement", + JSON: data, + }, nil) } func (h *handler) handleRead(w http.ResponseWriter, r *http.Request, resourceType, id string) { @@ -503,7 +506,7 @@ func (h *handler) handleUpdate(w http.ResponseWriter, r *http.Request, resourceT } func (h *handler) handlePatch(w http.ResponseWriter, r *http.Request, resourceType, id string) { - if err := h.authorizeWrite(r.Context(), "update", resourceType, id); err != nil { + if err := h.authorizeWrite(r.Context(), "patch", resourceType, id); err != nil { writeError(w, err) return } @@ -844,7 +847,7 @@ func (h *handler) authorizeBundleEntries(r *http.Request, body []byte) error { return err } case http.MethodPatch: - if err := h.authorizeWrite(r.Context(), "update", resourceType, id); err != nil { + if err := h.authorizeWrite(r.Context(), "patch", resourceType, id); err != nil { return err } case http.MethodDelete: diff --git a/pkg/http/hooks_test.go b/pkg/http/hooks_test.go index 6e6d7a94..fe315f10 100644 --- a/pkg/http/hooks_test.go +++ b/pkg/http/hooks_test.go @@ -11,6 +11,7 @@ import ( "github.com/degoke/health-ai-stack/pkg/hooks" hahttp "github.com/degoke/health-ai-stack/pkg/http" + "github.com/degoke/health-ai-stack/pkg/registry" "github.com/degoke/health-ai-stack/pkg/search" "github.com/degoke/health-ai-stack/pkg/store" "github.com/degoke/health-ai-stack/pkg/types" @@ -82,12 +83,14 @@ func TestOutgoingHookRunsOnSearchBundle(t *testing.T) { reg := hooks.NewRegistry() var seenAction hooks.Action var seenType string + var seenEnvelopeType string if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { seenAction = event.Action + seenType = event.ResourceType if event.Resource == nil { return errors.New("missing search bundle") } - seenType = event.Resource.ResourceType + seenEnvelopeType = event.Resource.ResourceType event.Resource.JSON = []byte(strings.ReplaceAll(string(event.Resource.JSON), `"Doe"`, `"REDACTED"`)) return nil }); err != nil { @@ -118,8 +121,11 @@ func TestOutgoingHookRunsOnSearchBundle(t *testing.T) { if seenAction != hooks.ActionSearch { t.Fatalf("outgoing action = %q, want search", seenAction) } - if seenType != "Bundle" { - t.Fatalf("outgoing resourceType = %q, want Bundle", seenType) + if seenType != "Patient" { + t.Fatalf("outgoing resourceType = %q, want Patient (query type, not Bundle)", seenType) + } + if seenEnvelopeType != "Bundle" { + t.Fatalf("outgoing resource envelope type = %q, want Bundle", seenEnvelopeType) } if !strings.Contains(rec.Body.String(), "REDACTED") { t.Fatalf("expected PHI-stripped search bundle, got %s", rec.Body.String()) @@ -132,8 +138,10 @@ func TestOutgoingHookRunsOnSearchBundle(t *testing.T) { func TestOutgoingHookRunsOnHistoryBundle(t *testing.T) { reg := hooks.NewRegistry() var seenAction hooks.Action + var seenType string if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { seenAction = event.Action + seenType = event.ResourceType if event.Resource == nil || event.Resource.ResourceType != "Bundle" { return errors.New("expected history bundle envelope") } @@ -163,7 +171,52 @@ func TestOutgoingHookRunsOnHistoryBundle(t *testing.T) { if seenAction != hooks.ActionHistory { t.Fatalf("outgoing action = %q, want history", seenAction) } + if seenType != "Patient" { + t.Fatalf("outgoing resourceType = %q, want Patient (query type, not Bundle)", seenType) + } if !strings.Contains(rec.Body.String(), "REDACTED") { t.Fatalf("expected PHI-stripped history bundle, got %s", rec.Body.String()) } } + +func TestOutgoingHookRunsOnMetadata(t *testing.T) { + reg := hooks.NewRegistry() + var seenAction hooks.Action + var seenType string + if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { + seenAction = event.Action + seenType = event.ResourceType + if event.Resource == nil { + return errors.New("missing capability statement") + } + event.Resource.JSON = []byte(strings.ReplaceAll(string(event.Resource.JSON), `"haistack-http"`, `"REDACTED"`)) + return nil + }); err != nil { + t.Fatal(err) + } + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{}, + CapabilitySource: fakeCapabilitySource{snapshot: registry.CapabilitySnapshot{ + FHIRVersion: "4.0.1", + Resources: []registry.ResourceCapability{{ResourceType: "Patient"}}, + }}, + ServerMetadata: hahttp.ServerMetadata{SoftwareName: "haistack-http"}, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodGet, "/fhir/metadata", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if seenAction != hooks.ActionMetadata { + t.Fatalf("outgoing action = %q, want metadata", seenAction) + } + if seenType != "CapabilityStatement" { + t.Fatalf("outgoing resourceType = %q, want CapabilityStatement", seenType) + } + if !strings.Contains(rec.Body.String(), "REDACTED") { + t.Fatalf("expected redacted metadata, got %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"haistack-http"`) { + t.Fatalf("original software name still present: %s", rec.Body.String()) + } +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 27823874..79be37c9 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -870,6 +870,31 @@ func TestDeleteUsesDeleteAuthorizationOperation(t *testing.T) { } } +func TestPatchUsesPatchAuthorizationOperation(t *testing.T) { + checker := &recordingAuthChecker{allow: true} + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{ + patchFn: func(_ context.Context, _, id string, _ []byte) (*types.ResourceEnvelope, error) { + return patientEnvelope(id, "Patched"), nil + }, + }, + PrincipalResolver: func(_ context.Context, _ *http.Request) (auth.Principal, auth.TenantContext, error) { + return auth.Principal{ID: "user-1"}, auth.TenantContext{TenantID: "t1"}, nil + }, + AuthChecker: checker, + }) + + rec := doRequestWithHeaders(t, handler, http.MethodPatch, "/fhir/Patient/pat-1", []byte(`[{"op":"replace","path":"/name/0/family","value":"Patched"}]`), map[string]string{ + "Content-Type": "application/json-patch+json", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if len(checker.writeCalls) != 1 || checker.writeCalls[0] != "patch:Patient/pat-1" { + t.Fatalf("write calls = %v", checker.writeCalls) + } +} + func TestIfMatchRequiresAtomicResourceService(t *testing.T) { svc := &fakeResourceService{ readFn: func(_ context.Context, _, id string) (*types.ResourceEnvelope, error) { diff --git a/pkg/http/writer.go b/pkg/http/writer.go index 64a1849c..b5da44ea 100644 --- a/pkg/http/writer.go +++ b/pkg/http/writer.go @@ -88,8 +88,12 @@ func writeEnvelope(w http.ResponseWriter, status int, envelope *types.ResourceEn *event = *formatted.hookEvent } event.Resource = envelope - event.ResourceType = envelope.ResourceType - event.ID = envelope.ID + if event.ResourceType == "" { + event.ResourceType = envelope.ResourceType + } + if event.ID == "" { + event.ID = envelope.ID + } ctx := formatted.hookCtx if ctx == nil { ctx = context.Background() From 1726480d0549ec0436f0c995a2e2b03155d8ec44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 11:09:42 +0000 Subject: [PATCH 4/5] fix: tag batch bundles as ActionBatch POST /fhir incoming and outgoing hooks used ActionTransaction for batch bundles because routing cannot see Bundle.type. Parse the body first, then fire incoming/outgoing as batch. Core ProcessBatchBundle now runs post-commit with the response Bundle. Co-authored-by: Adegoke Adewoye --- pkg/core/batch.go | 7 ++++-- pkg/core/hooks_test.go | 42 +++++++++++++++++++++++++++++++ pkg/hooks/hooks.go | 1 + pkg/http/handler.go | 56 +++++++++++++++++++++--------------------- pkg/http/hooks.go | 13 ++++++++-- pkg/http/hooks_test.go | 38 ++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 32 deletions(-) diff --git a/pkg/core/batch.go b/pkg/core/batch.go index 2358f04d..d2a327de 100644 --- a/pkg/core/batch.go +++ b/pkg/core/batch.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/types" ) @@ -39,11 +40,13 @@ func (s *ResourceService) ProcessBatchBundle(ctx context.Context, bundle *types. if err != nil { return nil, exceptionErr("build batch response bundle", err) } - return &types.ResourceEnvelope{ + response := &types.ResourceEnvelope{ ResourceType: "Bundle", JSON: responseJSON, Hash: mustHash(responseJSON), - }, nil + } + s.runPostCommit(ctx, hooks.ActionBatch, response, nil) + return response, nil } type batchBundle struct { diff --git a/pkg/core/hooks_test.go b/pkg/core/hooks_test.go index 9ed12a6f..717f8c20 100644 --- a/pkg/core/hooks_test.go +++ b/pkg/core/hooks_test.go @@ -277,6 +277,48 @@ func TestTransactionPostCommitSeesBundleJSON(t *testing.T) { } } +func TestBatchPostCommitSeesBundleJSON(t *testing.T) { + ctx := context.Background() + var postAction hooks.Action + var postJSON string + reg := hooks.NewRegistry() + if err := reg.On(hooks.PostCommit, func(_ context.Context, event *hooks.Event) error { + if event.Action != hooks.ActionBatch { + return nil + } + postAction = event.Action + if event.Resource == nil { + t.Fatal("batch post-commit resource was nil") + } + postJSON = string(event.Resource.JSON) + return nil + }); err != nil { + t.Fatal(err) + } + + svc := newHookedService(t, reg) + if _, err := svc.Create(ctx, &types.ResourceEnvelope{ + ResourceType: "Patient", + JSON: []byte(`{"resourceType":"Patient","id":"p1","name":[{"family":"Batch"}]}`), + }); err != nil { + t.Fatal(err) + } + bundle := []byte(`{"resourceType":"Bundle","type":"batch","entry":[{"request":{"method":"GET","url":"Patient/p1"}}]}`) + resp, err := svc.ProcessBatchBundle(ctx, &types.ResourceEnvelope{ResourceType: "Bundle", JSON: bundle}) + if err != nil { + t.Fatalf("ProcessBatchBundle: %v", err) + } + if postAction != hooks.ActionBatch { + t.Fatalf("post-commit action = %q, want batch", postAction) + } + if postJSON == "" || !strings.Contains(postJSON, "batch-response") { + t.Fatalf("post-commit JSON = %s", postJSON) + } + if postJSON != string(resp.JSON) { + t.Fatalf("post-commit JSON does not match response envelope") + } +} + func newHookedService(t *testing.T, reg *hooks.Registry) *core.ResourceService { t.Helper() mem := newMemBackend() diff --git a/pkg/hooks/hooks.go b/pkg/hooks/hooks.go index 78e907cc..5a03fde1 100644 --- a/pkg/hooks/hooks.go +++ b/pkg/hooks/hooks.go @@ -30,6 +30,7 @@ const ( ActionSearch Action = "search" ActionHistory Action = "history" ActionTransaction Action = "transaction" + ActionBatch Action = "batch" ActionOperation Action = "operation" ActionMetadata Action = "metadata" ) diff --git a/pkg/http/handler.go b/pkg/http/handler.go index f1f57132..c945e022 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "github.com/degoke/health-ai-stack/pkg/hooks" "github.com/degoke/health-ai-stack/pkg/search" "github.com/degoke/health-ai-stack/pkg/smart" "github.com/degoke/health-ai-stack/pkg/types" @@ -39,9 +40,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } w = withHookContext(w, r.Context(), h.cfg.Hooks, route, r.Method) - if err := h.runIncoming(r.Context(), route, r.Method); err != nil { - writeError(w, err) - return + if route.kind != routeTransaction { + if err := h.runIncoming(r.Context(), route, r.Method); err != nil { + writeError(w, err) + return + } } switch route.kind { @@ -740,39 +743,31 @@ func (h *handler) handleBundlePost(w http.ResponseWriter, r *http.Request) { writeError(w, invalidRequest("parse bundle", err)) return } - if isTxn { - if err := h.authorizeWrite(r.Context(), "transaction", "Bundle", ""); err != nil { - writeError(w, err) - return - } - envelope, err := parseBundleBody(h.cfg.Codec, "application/fhir+json", body) + isBatch := false + if !isTxn { + isBatch, err = isBatchBundle(body) if err != nil { - writeError(w, err) - return - } - if err := h.authorizeBundleEntries(r, body); err != nil { - writeError(w, err) + writeError(w, invalidRequest("parse bundle", err)) return } - response, err := h.cfg.ResourceService.ProcessTransactionBundle(r.Context(), envelope) - if err != nil { - writeError(w, err) + if !isBatch { + writeError(w, invalidRequest("POST /fhir accepts transaction or batch bundles", nil)) return } - writeEnvelope(w, http.StatusOK, response, nil) - return } - - isBatch, err := isBatchBundle(body) - if err != nil { - writeError(w, invalidRequest("parse bundle", err)) - return + action := hooks.ActionTransaction + authOp := "transaction" + if isBatch { + action = hooks.ActionBatch + authOp = "batch" } - if !isBatch { - writeError(w, invalidRequest("POST /fhir accepts transaction or batch bundles", nil)) + event := &hooks.Event{Action: action, ResourceType: "Bundle"} + h.bindHookEvent(w, event) + if err := h.runIncomingEvent(r.Context(), event); err != nil { + writeError(w, err) return } - if err := h.authorizeWrite(r.Context(), "batch", "Bundle", ""); err != nil { + if err := h.authorizeWrite(r.Context(), authOp, "Bundle", ""); err != nil { writeError(w, err) return } @@ -785,7 +780,12 @@ func (h *handler) handleBundlePost(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - response, err := h.cfg.ResourceService.ProcessBatchBundle(r.Context(), envelope) + var response *types.ResourceEnvelope + if isTxn { + response, err = h.cfg.ResourceService.ProcessTransactionBundle(r.Context(), envelope) + } else { + response, err = h.cfg.ResourceService.ProcessBatchBundle(r.Context(), envelope) + } if err != nil { writeError(w, err) return diff --git a/pkg/http/hooks.go b/pkg/http/hooks.go index b3692832..a1189cc2 100644 --- a/pkg/http/hooks.go +++ b/pkg/http/hooks.go @@ -10,10 +10,13 @@ import ( ) func (h *handler) runIncoming(ctx context.Context, route parsedRoute, method string) error { - if h == nil || h.cfg.Hooks == nil { + return h.runIncomingEvent(ctx, incomingEvent(route, method)) +} + +func (h *handler) runIncomingEvent(ctx context.Context, event *hooks.Event) error { + if h == nil || h.cfg.Hooks == nil || event == nil { return nil } - event := incomingEvent(route, method) if err := h.cfg.Hooks.Run(ctx, hooks.Incoming, event); err != nil { var svcErr *core.ServiceError if errors.As(err, &svcErr) { @@ -24,6 +27,12 @@ func (h *handler) runIncoming(ctx context.Context, route parsedRoute, method str return nil } +func (h *handler) bindHookEvent(w http.ResponseWriter, event *hooks.Event) { + if formatted, ok := w.(*formattedResponseWriter); ok { + formatted.hookEvent = event + } +} + func incomingEvent(route parsedRoute, method string) *hooks.Event { return &hooks.Event{ Action: actionFromRoute(route, method), diff --git a/pkg/http/hooks_test.go b/pkg/http/hooks_test.go index fe315f10..b10c122e 100644 --- a/pkg/http/hooks_test.go +++ b/pkg/http/hooks_test.go @@ -220,3 +220,41 @@ func TestOutgoingHookRunsOnMetadata(t *testing.T) { t.Fatalf("original software name still present: %s", rec.Body.String()) } } + +func TestBundleHooksUseBatchAction(t *testing.T) { + reg := hooks.NewRegistry() + var incoming, outgoing hooks.Action + if err := reg.On(hooks.Incoming, func(_ context.Context, event *hooks.Event) error { + incoming = event.Action + return nil + }); err != nil { + t.Fatal(err) + } + if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { + outgoing = event.Action + return nil + }); err != nil { + t.Fatal(err) + } + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{ + batch: func(_ context.Context, bundle *types.ResourceEnvelope) (*types.ResourceEnvelope, error) { + return &types.ResourceEnvelope{ + ResourceType: "Bundle", + JSON: []byte(`{"resourceType":"Bundle","type":"batch-response","entry":[]}`), + }, nil + }, + }, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodPost, "/fhir", []byte(`{"resourceType":"Bundle","type":"batch","entry":[]}`)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if incoming != hooks.ActionBatch { + t.Fatalf("incoming action = %q, want batch", incoming) + } + if outgoing != hooks.ActionBatch { + t.Fatalf("outgoing action = %q, want batch", outgoing) + } +} From 05f55372fd2c515801a0bf7bfe800040d90d728e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 11:49:57 +0000 Subject: [PATCH 5/5] fix: run outgoing hooks on Patient $everything bundles Route $everything through writeBundleJSON like search and history so an outgoing hook can inspect or redact the compartment searchset. Co-authored-by: Adegoke Adewoye --- pkg/http/everything.go | 2 +- pkg/http/hooks_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/pkg/http/everything.go b/pkg/http/everything.go index a4cd7b4c..22ea736e 100644 --- a/pkg/http/everything.go +++ b/pkg/http/everything.go @@ -89,7 +89,7 @@ func (h *handler) handleEverything(w http.ResponseWriter, r *http.Request, route writeError(w, invalidRequest("build $everything bundle", err)) return } - writeResource(w, http.StatusOK, data, nil) + writeBundleJSON(w, http.StatusOK, data) } func everythingPageURL(basePath, patientID string, params url.Values, offset, count int) string { diff --git a/pkg/http/hooks_test.go b/pkg/http/hooks_test.go index b10c122e..88e76c01 100644 --- a/pkg/http/hooks_test.go +++ b/pkg/http/hooks_test.go @@ -179,6 +179,63 @@ func TestOutgoingHookRunsOnHistoryBundle(t *testing.T) { } } +func TestOutgoingHookRunsOnEverythingBundle(t *testing.T) { + reg := hooks.NewRegistry() + var seenAction hooks.Action + var seenType, seenOp, seenEnvelopeType string + if err := reg.On(hooks.Outgoing, func(_ context.Context, event *hooks.Event) error { + seenAction = event.Action + seenType = event.ResourceType + seenOp = event.Operation + if event.Resource == nil { + return errors.New("missing $everything bundle") + } + seenEnvelopeType = event.Resource.ResourceType + event.Resource.JSON = []byte(strings.ReplaceAll(string(event.Resource.JSON), `"Doe"`, `"REDACTED"`)) + return nil + }); err != nil { + t.Fatal(err) + } + handler := newTestHandler(t, hahttp.Config{ + ResourceService: &fakeResourceService{ + readFn: func(_ context.Context, resourceType, id string) (*types.ResourceEnvelope, error) { + if resourceType == "Patient" && id == "pat-1" { + return patientEnvelope(id, "Doe"), nil + } + return nil, errors.New("not found") + }, + }, + SearchService: &fakeSearchService{ + searchFn: func(_ context.Context, resourceType string, _ url.Values) (*search.SearchBundle, error) { + return &search.SearchBundle{ResourceType: resourceType}, nil + }, + }, + Hooks: reg, + }) + rec := doRequest(t, handler, http.MethodGet, "/fhir/Patient/pat-1/$everything?_type=Observation", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if seenAction != hooks.ActionOperation { + t.Fatalf("outgoing action = %q, want operation", seenAction) + } + if seenType != "Patient" { + t.Fatalf("outgoing resourceType = %q, want Patient (query type, not Bundle)", seenType) + } + if seenOp != "$everything" { + t.Fatalf("outgoing operation = %q, want $everything", seenOp) + } + if seenEnvelopeType != "Bundle" { + t.Fatalf("outgoing resource envelope type = %q, want Bundle", seenEnvelopeType) + } + if !strings.Contains(rec.Body.String(), "REDACTED") { + t.Fatalf("expected PHI-stripped $everything bundle, got %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"Doe"`) { + t.Fatalf("original family still present: %s", rec.Body.String()) + } +} + func TestOutgoingHookRunsOnMetadata(t *testing.T) { reg := hooks.NewRegistry() var seenAction hooks.Action