diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java
new file mode 100644
index 00000000..e01010ae
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java
@@ -0,0 +1,387 @@
+package uk.co.compendiumdev.thingifier.adapter.http.apihandlers;
+
+import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipInstanceRoute;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute;
+import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleContext;
+import uk.co.compendiumdev.thingifier.api.callbacks.CallbackFailurePolicy;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallbackDefinition;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationContext;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationResult;
+import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
+import uk.co.compendiumdev.thingifier.api.http.ApiRequestEnvelope;
+import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext;
+import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields;
+import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock;
+import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
+import uk.co.compendiumdev.thingifier.application.ThingCommandResult;
+import uk.co.compendiumdev.thingifier.application.command.ThingWriteCommand;
+import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec;
+import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams;
+
+/**
+ * Runs route-level operation callbacks after Thingifier has produced a route-shaped response.
+ *
+ *
The applier is shared by direct and HTTP-backed processing. It keeps callbacks separate from
+ * response policies: policies shape the response first, callbacks observe the selected route/result
+ * and can perform trusted application side effects before legacy response hooks run.
+ */
+public final class RouteOperationCallbackApplier {
+
+ private static final Logger LOGGER =
+ Logger.getLogger(RouteOperationCallbackApplier.class.getName());
+
+ private final ThingifierApiRuntime runtime;
+
+ /**
+ * Creates an applier for the current API runtime.
+ *
+ * @param runtime runtime used to find route rules and route metadata
+ */
+ public RouteOperationCallbackApplier(final ThingifierApiRuntime runtime) {
+ this.runtime = runtime;
+ }
+
+ /**
+ * Runs callbacks registered on the matched route rule.
+ *
+ * @param verb route verb for route-rule lookup
+ * @param publicPath public request path
+ * @param response route-shaped API response
+ * @param requestContext active request context
+ * @param lifecycle lifecycle context when processing an HTTP/lifecycle request, otherwise null
+ * @param request parsed request envelope when available
+ * @return original response, or a callback failure response when configured to fail the request
+ */
+ public ApiResponse apply(
+ final RoutingVerb verb,
+ final String publicPath,
+ final ApiResponse response,
+ final ThingifierRequestContext requestContext,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request) {
+ if (response == null) {
+ return null;
+ }
+
+ final uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule routeRule =
+ routeRuleFor(verb, publicPath).orElse(null);
+ if (routeRule == null || !routeRule.hasOperationCallbacks()) {
+ return response;
+ }
+
+ final ThingRoute route = route(lifecycle, verb, publicPath);
+ final ThingifierApiOperationContext context =
+ contextFor(verb, publicPath, route, routeRule, requestContext, lifecycle, request);
+ final ThingifierApiOperationResult result =
+ resultFor(response, lifecycle, operationTypeFor(verb, lifecycle));
+
+ for (ThingifierApiOperationCallbackDefinition definition : routeRule.operationCallbacks()) {
+ if (!definition.matches(result)) {
+ continue;
+ }
+ try {
+ definition.callback().run(context, result);
+ } catch (Exception exception) {
+ logCallbackFailure(definition, verb, publicPath, response, exception);
+ if (definition.failurePolicy() == CallbackFailurePolicy.FAIL_REQUEST) {
+ return ApiResponse.error(
+ 500, callbackFailureMessage(definition, verb, publicPath));
+ }
+ }
+ }
+ return response;
+ }
+
+ private Optional routeRuleFor(
+ final RoutingVerb verb, final String publicPath) {
+ return runtime.apiSpec()
+ .ruleFor(verb, publicPath, runtime.apiConfig().getApiEndPointPrefix());
+ }
+
+ private ThingRoute route(
+ final ThingifierApiLifecycleContext lifecycle,
+ final RoutingVerb verb,
+ final String publicPath) {
+ return lifecycle == null ? runtime.routeFor(verb, publicPath) : lifecycle.route();
+ }
+
+ private ThingifierApiOperationContext contextFor(
+ final RoutingVerb verb,
+ final String publicPath,
+ final ThingRoute route,
+ final uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule routeRule,
+ final ThingifierRequestContext requestContext,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request) {
+ return new ThingifierApiOperationContext(
+ verb,
+ publicPath,
+ route,
+ routeRule,
+ targetEntityName(route, lifecycle),
+ targetIdentifier(route, lifecycle),
+ parentEntityName(route, lifecycle),
+ parentIdentifier(route, lifecycle),
+ relationshipName(route, lifecycle),
+ childIdentifier(route, lifecycle),
+ requestContext == null ? null : requestContext.dataScopeName(),
+ requestContext == null ? null : requestContext.store(),
+ requestContext == null
+ ? java.util.Map.of()
+ : requestContext.authenticatedPrincipals(),
+ requestHeaders(requestContext, lifecycle, request),
+ queryParams(lifecycle, request),
+ bodyFields(lifecycle, request),
+ rawBody(lifecycle, request),
+ runtime.apiConfig());
+ }
+
+ private ThingifierApiOperationResult resultFor(
+ final ApiResponse response,
+ final ThingifierApiLifecycleContext lifecycle,
+ final String operationType) {
+ final ThingCommandResult writeResult =
+ lifecycle == null ? null : lifecycle.writeCommandResult();
+ return new ThingifierApiOperationResult(
+ response.getStatusCode(), operationType, response, writeResult);
+ }
+
+ private HttpHeadersBlock requestHeaders(
+ final ThingifierRequestContext requestContext,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request) {
+ if (request != null) {
+ return request.headers();
+ }
+ if (lifecycle != null) {
+ return lifecycle.headers();
+ }
+ return requestContext == null ? new HttpHeadersBlock() : requestContext.headers();
+ }
+
+ private QueryFilterParams queryParams(
+ final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request) {
+ if (request != null) {
+ return request.queryParams();
+ }
+ if (lifecycle != null) {
+ return lifecycle.queryParams();
+ }
+ return new QueryFilterParams();
+ }
+
+ private ApiBodyFields bodyFields(
+ final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request) {
+ if (request != null) {
+ return request.bodyFields();
+ }
+ if (lifecycle != null) {
+ return lifecycle.bodyFields();
+ }
+ return ApiBodyFields.empty();
+ }
+
+ private String rawBody(
+ final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request) {
+ if (request != null) {
+ return request.body();
+ }
+ if (lifecycle != null) {
+ return lifecycle.rawBody();
+ }
+ return "";
+ }
+
+ private String targetEntityName(
+ final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null && lifecycle.targetEntity() != null) {
+ return lifecycle.targetEntity().getName();
+ }
+ if (route instanceof CollectionRoute) {
+ return ((CollectionRoute) route).entity().name();
+ }
+ if (route instanceof InstanceRoute) {
+ return ((InstanceRoute) route).entity().name();
+ }
+ if (route instanceof RelationshipCollectionRoute) {
+ return relationshipTargetEntityName((RelationshipCollectionRoute) route);
+ }
+ if (route instanceof RelationshipInstanceRoute) {
+ return relationshipTargetEntityName((RelationshipInstanceRoute) route);
+ }
+ return null;
+ }
+
+ private String targetIdentifier(
+ final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null) {
+ return lifecycle.targetIdentifier();
+ }
+ if (route instanceof InstanceRoute) {
+ return ((InstanceRoute) route).identifier();
+ }
+ if (route instanceof RelationshipInstanceRoute) {
+ return ((RelationshipInstanceRoute) route).childIdentifier();
+ }
+ return null;
+ }
+
+ private String parentEntityName(
+ final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null && lifecycle.parentEntity() != null) {
+ return lifecycle.parentEntity().getName();
+ }
+ if (route instanceof RelationshipCollectionRoute) {
+ return ((RelationshipCollectionRoute) route).parentEntity().name();
+ }
+ if (route instanceof RelationshipInstanceRoute) {
+ return ((RelationshipInstanceRoute) route).parentEntity().name();
+ }
+ return null;
+ }
+
+ private String parentIdentifier(
+ final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null) {
+ return lifecycle.parentIdentifier();
+ }
+ if (route instanceof RelationshipCollectionRoute) {
+ return ((RelationshipCollectionRoute) route).parentIdentifier();
+ }
+ if (route instanceof RelationshipInstanceRoute) {
+ return ((RelationshipInstanceRoute) route).parentIdentifier();
+ }
+ return null;
+ }
+
+ private String relationshipName(
+ final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null) {
+ return lifecycle.relationshipName();
+ }
+ if (route instanceof RelationshipCollectionRoute) {
+ return ((RelationshipCollectionRoute) route).relationshipName();
+ }
+ if (route instanceof RelationshipInstanceRoute) {
+ return ((RelationshipInstanceRoute) route).relationshipName();
+ }
+ return null;
+ }
+
+ private String childIdentifier(
+ final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null) {
+ return lifecycle.childIdentifier();
+ }
+ if (route instanceof RelationshipInstanceRoute) {
+ return ((RelationshipInstanceRoute) route).childIdentifier();
+ }
+ return null;
+ }
+
+ private String relationshipTargetEntityName(final RelationshipCollectionRoute route) {
+ return relationshipTargetEntityName(route.parentEntity().name(), route.relationshipName());
+ }
+
+ private String relationshipTargetEntityName(final RelationshipInstanceRoute route) {
+ return relationshipTargetEntityName(route.parentEntity().name(), route.relationshipName());
+ }
+
+ private String relationshipTargetEntityName(
+ final String parentEntityName, final String relationshipName) {
+ final ThingRoute parentRoute = runtime.routeFor(RoutingVerb.GET, parentEntityName);
+ if (!(parentRoute instanceof CollectionRoute)) {
+ return null;
+ }
+ for (RelationshipSpec relationship :
+ ((CollectionRoute) parentRoute).entity().relationships()) {
+ if (relationship.name().equals(relationshipName)) {
+ return relationship.toEntityName();
+ }
+ }
+ return null;
+ }
+
+ private String operationTypeFor(
+ final RoutingVerb verb, final ThingifierApiLifecycleContext lifecycle) {
+ if (lifecycle != null && lifecycle.writeCommand() != null) {
+ return operationTypeFor(lifecycle.writeCommand());
+ }
+ if (verb == RoutingVerb.QUERY) {
+ return "QUERY";
+ }
+ if (verb == RoutingVerb.GET || verb == RoutingVerb.HEAD) {
+ return "READ";
+ }
+ if (verb == RoutingVerb.DELETE) {
+ return "DELETE";
+ }
+ if (verb == RoutingVerb.PATCH) {
+ return "PATCH";
+ }
+ if (verb == RoutingVerb.PUT) {
+ return "REPLACE";
+ }
+ if (verb == RoutingVerb.POST) {
+ return "WRITE";
+ }
+ return "";
+ }
+
+ private String operationTypeFor(final ThingWriteCommand command) {
+ final String commandName = command.getClass().getSimpleName();
+ switch (commandName) {
+ case "CreateThingCommand":
+ return "CREATE";
+ case "AmendThingCommand":
+ return "UPDATE";
+ case "ReplaceThingCommand":
+ return "REPLACE";
+ case "PatchThingDocumentCommand":
+ return "PATCH";
+ case "DeleteThingCommand":
+ return "DELETE";
+ case "CreateAndConnectRelationshipCommand":
+ return "CREATE_AND_CONNECT";
+ case "ConnectExistingRelationshipCommand":
+ return "CONNECT";
+ case "UpdateConnectedRelationshipCommand":
+ return "UPDATE_CONNECTED";
+ case "DisconnectRelationshipCommand":
+ return "DISCONNECT";
+ case "RelateThingCommand":
+ return "RELATE";
+ default:
+ return commandName;
+ }
+ }
+
+ private void logCallbackFailure(
+ final ThingifierApiOperationCallbackDefinition definition,
+ final RoutingVerb verb,
+ final String publicPath,
+ final ApiResponse response,
+ final Exception exception) {
+ LOGGER.log(
+ Level.SEVERE,
+ String.format(
+ "Route operation callback '%s' failed for %s %s after status %d",
+ definition.name(), verb, publicPath, response.getStatusCode()),
+ exception);
+ }
+
+ private String callbackFailureMessage(
+ final ThingifierApiOperationCallbackDefinition definition,
+ final RoutingVerb verb,
+ final String publicPath) {
+ return String.format(
+ "Route operation callback '%s' failed for %s %s",
+ definition.name(), verb, publicPath);
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java
index 6609f5a9..470eed56 100644
--- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java
@@ -6,6 +6,7 @@
import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.FixedRouteResourcePreparer;
import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteApiResponsePolicyApplier;
import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteAuthPolicy;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteOperationCallbackApplier;
import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime;
import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute;
import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleContext;
@@ -135,6 +136,7 @@ public ApiResponse get(
request.path(),
context,
lifecycle,
+ request,
() -> get.handle(request.path(), request.queryParams(), context, lifecycle));
}
@@ -187,6 +189,7 @@ public ApiResponse head(
request.path(),
context,
lifecycle,
+ request,
() -> {
final ApiResponse response =
get.handle(request.path(), request.queryParams(), context, lifecycle);
@@ -220,6 +223,7 @@ public ApiResponse query(
request.path(),
context,
lifecycle,
+ request,
() ->
query.handle(
request.path(),
@@ -268,6 +272,7 @@ public ApiResponse delete(
request.path(),
context,
lifecycle,
+ request,
() -> delete.handle(request.path(), request.queryParams(), context, lifecycle));
}
@@ -321,6 +326,7 @@ public ApiResponse post(
request.path(),
context,
lifecycle,
+ request,
() ->
post.handle(
request.path(),
@@ -397,6 +403,7 @@ public ApiResponse put(
request.path(),
context,
lifecycle,
+ request,
() ->
put.handle(
request.path(),
@@ -484,6 +491,7 @@ public ApiResponse patch(
request.path(),
context,
lifecycle,
+ request,
() ->
patch.handle(
request.path(),
@@ -558,19 +566,44 @@ private ApiResponse withAuthorizedResponsePolicy(
final ThingifierRequestContext context,
final ThingifierApiLifecycleContext lifecycle,
final Supplier action) {
+ return withAuthorizedResponsePolicy(verb, url, context, lifecycle, null, action);
+ }
+
+ /**
+ * Applies auth, fixed-resource preparation, response policy, and route operation callbacks.
+ *
+ * Callbacks run after response policies so they see the route-shaped API result, and before
+ * legacy HTTP response hooks so application code still has one final compatibility hook phase.
+ *
+ * @param verb routing verb used for route-rule lookup
+ * @param url generated API path
+ * @param context request context containing the active store
+ * @param lifecycle lifecycle context when called through HTTP processing, otherwise null
+ * @param request parsed request envelope, or null for older direct-call helpers
+ * @param action handler action to run when auth allows the request
+ * @return response after auth, response policy, and route callbacks have been applied
+ */
+ private ApiResponse withAuthorizedResponsePolicy(
+ final RoutingVerb verb,
+ final String url,
+ final ThingifierRequestContext context,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request,
+ final Supplier action) {
final ApiResponse authResponse =
lifecycle == null ? authPolicy.rejectIfNotAuthorized(verb, url, context) : null;
if (authResponse != null) {
- return withResponsePolicy(verb, url, authResponse, context);
+ return withResponsePolicy(verb, url, authResponse, context, lifecycle, request);
}
final ThingRoute route =
lifecycle == null ? runtime.routeFor(verb, url) : lifecycle.route();
final ApiResponse fixedResourceResponse =
new FixedRouteResourcePreparer(runtime).prepare(verb, url, route, context);
if (fixedResourceResponse != null) {
- return withResponsePolicy(verb, url, fixedResourceResponse, context);
+ return withResponsePolicy(
+ verb, url, fixedResourceResponse, context, lifecycle, request);
}
- return withResponsePolicy(verb, url, action.get(), context);
+ return withResponsePolicy(verb, url, action.get(), context, lifecycle, request);
}
/**
@@ -604,15 +637,20 @@ private ApiResponse withResponsePolicy(
final RoutingVerb verb,
final String url,
final ApiResponse response,
- final ThingifierRequestContext context) {
+ final ThingifierRequestContext context,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request) {
final ApiResponse responseWithRepository = withRepository(response, context);
- return new RouteApiResponsePolicyApplier(runtime)
- .apply(
- verb,
- url,
- responseWithRepository,
- context.headers(),
- apiResponse -> applyResponseEntityView(verb, url, apiResponse));
+ final ApiResponse policyResponse =
+ new RouteApiResponsePolicyApplier(runtime)
+ .apply(
+ verb,
+ url,
+ responseWithRepository,
+ context.headers(),
+ apiResponse -> applyResponseEntityView(verb, url, apiResponse));
+ return new RouteOperationCallbackApplier(runtime)
+ .apply(verb, url, policyResponse, context, lifecycle, request);
}
/**
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java
new file mode 100644
index 00000000..a109f49c
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java
@@ -0,0 +1,26 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+/**
+ * Controls how Thingifier reacts when a route operation callback throws.
+ *
+ * Callbacks are trusted application code running after Thingifier has decided an operation
+ * result. Applications can choose whether a side-effect failure should fail the visible API request
+ * or be logged while preserving the original response.
+ */
+public enum CallbackFailurePolicy {
+ /**
+ * Convert the callback exception into a 500 API response.
+ *
+ *
This is the default because silently skipping application side effects can leave
+ * application-owned state inconsistent with Thingifier-managed data.
+ */
+ FAIL_REQUEST,
+
+ /**
+ * Log the callback exception and preserve the original operation response.
+ *
+ *
Use this when the callback is observational, such as diagnostics or best-effort metrics,
+ * and the API operation should not fail because the callback failed.
+ */
+ LOG_AND_CONTINUE
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java
new file mode 100644
index 00000000..61f31e31
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java
@@ -0,0 +1,21 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+/**
+ * Trusted application callback invoked after a route operation has produced an API result.
+ *
+ *
Use route operation callbacks for application side effects such as audit logging, projections,
+ * cache invalidation, or synchronising app-owned state. Response shaping should stay in route
+ * response policies or response hooks so callbacks can remain focused on observing the completed
+ * operation.
+ */
+@FunctionalInterface
+public interface ThingifierApiOperationCallback {
+
+ /**
+ * Runs the application callback for one completed route operation.
+ *
+ * @param context immutable route, request, auth, and data-scope information
+ * @param result immutable operation outcome details
+ */
+ void run(ThingifierApiOperationContext context, ThingifierApiOperationResult result);
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java
new file mode 100644
index 00000000..448ea488
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java
@@ -0,0 +1,156 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule;
+
+/**
+ * Runtime-only registration for one route operation callback.
+ *
+ *
The definition is code-only by design. Java callbacks cannot safely round-trip through YAML or
+ * OpenAPI, so Thingifier stores them only in the in-memory API contract and uses the name for
+ * diagnostics.
+ */
+public final class ThingifierApiOperationCallbackDefinition {
+
+ /** Outcome selector used when deciding whether a callback should run. */
+ public enum Outcome {
+ /** Run for any completed outcome. */
+ ANY,
+
+ /** Run only for 2xx/3xx operation responses. */
+ SUCCESS,
+
+ /** Run only for non-success operation responses. */
+ FAILURE,
+
+ /** Run only when the final status code matches {@link #statusCode()}. */
+ STATUS
+ }
+
+ private final ThingifierApiRouteRule routeRule;
+ private final String name;
+ private final Outcome outcome;
+ private final Integer statusCode;
+ private final ThingifierApiOperationCallback callback;
+ private CallbackFailurePolicy failurePolicy;
+
+ /**
+ * Creates a callback registration.
+ *
+ * @param routeRule route that owns the callback
+ * @param name stable diagnostic name
+ * @param outcome outcome selector
+ * @param statusCode status code for {@link Outcome#STATUS}, otherwise null
+ * @param callback trusted application callback
+ */
+ public ThingifierApiOperationCallbackDefinition(
+ final ThingifierApiRouteRule routeRule,
+ final String name,
+ final Outcome outcome,
+ final Integer statusCode,
+ final ThingifierApiOperationCallback callback) {
+ if (routeRule == null) {
+ throw new IllegalArgumentException("route rule is required");
+ }
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException("callback name is required");
+ }
+ if (outcome == null) {
+ throw new IllegalArgumentException("callback outcome is required");
+ }
+ if (outcome == Outcome.STATUS && statusCode == null) {
+ throw new IllegalArgumentException("status callback requires a status code");
+ }
+ if (callback == null) {
+ throw new IllegalArgumentException("callback is required");
+ }
+ this.routeRule = routeRule;
+ this.name = name.trim();
+ this.outcome = outcome;
+ this.statusCode = statusCode;
+ this.callback = callback;
+ this.failurePolicy = CallbackFailurePolicy.FAIL_REQUEST;
+ }
+
+ /**
+ * Sets the failure policy for this callback.
+ *
+ * @param policy callback exception handling policy
+ * @return owning route rule so route configuration can continue fluently
+ */
+ public ThingifierApiRouteRule onCallbackFailure(final CallbackFailurePolicy policy) {
+ if (policy == null) {
+ throw new IllegalArgumentException("callback failure policy is required");
+ }
+ this.failurePolicy = policy;
+ return routeRule;
+ }
+
+ /**
+ * Returns the stable diagnostic callback name.
+ *
+ * @return callback name
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Returns the outcome selector for this callback.
+ *
+ * @return configured outcome selector
+ */
+ public Outcome outcome() {
+ return outcome;
+ }
+
+ /**
+ * Returns the status code matched by status-specific callbacks.
+ *
+ * @return status code, or null for non-status callbacks
+ */
+ public Integer statusCode() {
+ return statusCode;
+ }
+
+ /**
+ * Returns the application callback.
+ *
+ * @return trusted callback
+ */
+ public ThingifierApiOperationCallback callback() {
+ return callback;
+ }
+
+ /**
+ * Returns the configured callback failure policy.
+ *
+ * @return failure policy
+ */
+ public CallbackFailurePolicy failurePolicy() {
+ return failurePolicy;
+ }
+
+ /**
+ * Reports whether this callback should run for the supplied result.
+ *
+ * @param result operation result
+ * @return true when the outcome selector matches
+ */
+ public boolean matches(final ThingifierApiOperationResult result) {
+ if (result == null) {
+ return false;
+ }
+ switch (outcome) {
+ case ANY:
+ return true;
+ case SUCCESS:
+ return result.successful();
+ case FAILURE:
+ return result.failed();
+ case STATUS:
+ return statusCode != null && statusCode == result.statusCode();
+ default:
+ return false;
+ }
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java
new file mode 100644
index 00000000..289c515a
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java
@@ -0,0 +1,286 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute;
+import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
+import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields;
+import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock;
+import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule;
+import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig;
+import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams;
+import uk.co.compendiumdev.thingifier.core.repository.ThingStore;
+
+/**
+ * Immutable route and request context supplied to route operation callbacks.
+ *
+ *
The context reflects trusted routing, authentication, and data-scope decisions that have
+ * already happened before the operation callback runs. Applications can use it to update app-owned
+ * state without reparsing public paths or re-discovering the active store.
+ */
+public final class ThingifierApiOperationContext {
+
+ private final RoutingVerb verb;
+ private final String publicPath;
+ private final ThingRoute route;
+ private final ThingifierApiRouteRule routeRule;
+ private final String targetEntityName;
+ private final String targetIdentifier;
+ private final String parentEntityName;
+ private final String parentIdentifier;
+ private final String relationshipName;
+ private final String childIdentifier;
+ private final String dataScopeName;
+ private final ThingStore store;
+ private final Map authenticatedPrincipals;
+ private final HttpHeadersBlock requestHeaders;
+ private final QueryFilterParams queryParams;
+ private final ApiBodyFields parsedRequestBody;
+ private final String rawRequestBody;
+ private final ThingifierApiConfig apiConfig;
+
+ /**
+ * Creates a callback context.
+ *
+ * @param verb route verb being processed
+ * @param publicPath public request path
+ * @param route resolved generated route
+ * @param routeRule matched route rule that owns the callback
+ * @param targetEntityName target entity name, or null
+ * @param targetIdentifier target identifier, or null
+ * @param parentEntityName relationship parent entity name, or null
+ * @param parentIdentifier relationship parent identifier, or null
+ * @param relationshipName relationship route name, or null
+ * @param childIdentifier relationship child identifier, or null
+ * @param dataScopeName active data-scope name
+ * @param store active store
+ * @param authenticatedPrincipals authenticated principals by scheme name
+ * @param requestHeaders request headers
+ * @param queryParams parsed query parameters
+ * @param parsedRequestBody parsed body fields
+ * @param rawRequestBody raw request body text
+ * @param apiConfig active API configuration
+ */
+ public ThingifierApiOperationContext(
+ final RoutingVerb verb,
+ final String publicPath,
+ final ThingRoute route,
+ final ThingifierApiRouteRule routeRule,
+ final String targetEntityName,
+ final String targetIdentifier,
+ final String parentEntityName,
+ final String parentIdentifier,
+ final String relationshipName,
+ final String childIdentifier,
+ final String dataScopeName,
+ final ThingStore store,
+ final Map authenticatedPrincipals,
+ final HttpHeadersBlock requestHeaders,
+ final QueryFilterParams queryParams,
+ final ApiBodyFields parsedRequestBody,
+ final String rawRequestBody,
+ final ThingifierApiConfig apiConfig) {
+ this.verb = verb;
+ this.publicPath = normalizedPublicPath(publicPath);
+ this.route = route;
+ this.routeRule = routeRule;
+ this.targetEntityName = targetEntityName;
+ this.targetIdentifier = targetIdentifier;
+ this.parentEntityName = parentEntityName;
+ this.parentIdentifier = parentIdentifier;
+ this.relationshipName = relationshipName;
+ this.childIdentifier = childIdentifier;
+ this.dataScopeName = dataScopeName;
+ this.store = store;
+ this.authenticatedPrincipals =
+ Map.copyOf(
+ authenticatedPrincipals == null
+ ? Map.of()
+ : new HashMap<>(authenticatedPrincipals));
+ this.requestHeaders = copyHeaders(requestHeaders);
+ this.queryParams = copyQueryParams(queryParams);
+ this.parsedRequestBody =
+ parsedRequestBody == null ? ApiBodyFields.empty() : parsedRequestBody;
+ this.rawRequestBody = rawRequestBody == null ? "" : rawRequestBody;
+ this.apiConfig = apiConfig;
+ }
+
+ /**
+ * @return route verb being processed
+ */
+ public RoutingVerb verb() {
+ return verb;
+ }
+
+ /**
+ * @return public path requested by the caller
+ */
+ public String publicPath() {
+ return publicPath;
+ }
+
+ /**
+ * @return resolved generated route target
+ */
+ public ThingRoute route() {
+ return route;
+ }
+
+ /**
+ * @return matched route rule that registered the callback
+ */
+ public ThingifierApiRouteRule routeRule() {
+ return routeRule;
+ }
+
+ /**
+ * @return route pattern declared on the matched route rule
+ */
+ public String matchedRoutePattern() {
+ return routeRule == null ? publicPath : routeRule.pathPattern();
+ }
+
+ /**
+ * @return targeted entity name when the route is entity-backed
+ */
+ public Optional targetEntityName() {
+ return Optional.ofNullable(targetEntityName);
+ }
+
+ /**
+ * @return targeted identifier for instance and fixed routes
+ */
+ public Optional targetIdentifier() {
+ return Optional.ofNullable(targetIdentifier);
+ }
+
+ /**
+ * @return relationship parent entity name when applicable
+ */
+ public Optional parentEntityName() {
+ return Optional.ofNullable(parentEntityName);
+ }
+
+ /**
+ * @return relationship parent identifier when applicable
+ */
+ public Optional parentIdentifier() {
+ return Optional.ofNullable(parentIdentifier);
+ }
+
+ /**
+ * @return relationship route name when applicable
+ */
+ public Optional relationshipName() {
+ return Optional.ofNullable(relationshipName);
+ }
+
+ /**
+ * @return relationship child identifier when applicable
+ */
+ public Optional childIdentifier() {
+ return Optional.ofNullable(childIdentifier);
+ }
+
+ /**
+ * @return active data-scope name after authentication had a chance to select it
+ */
+ public String dataScopeName() {
+ return dataScopeName;
+ }
+
+ /**
+ * @return active Thingifier store for the operation
+ */
+ public ThingStore store() {
+ return store;
+ }
+
+ /**
+ * Returns the principal for the common single-scheme case.
+ *
+ * @return first authenticated principal, or null when none was stored
+ */
+ public Object authenticatedPrincipal() {
+ if (authenticatedPrincipals.isEmpty()) {
+ return null;
+ }
+ return authenticatedPrincipals.values().iterator().next();
+ }
+
+ /**
+ * Returns the principal for a named security scheme.
+ *
+ * @param schemeName security scheme name
+ * @return authenticated principal, or null
+ */
+ public Object authenticatedPrincipal(final String schemeName) {
+ return authenticatedPrincipals.get(schemeName);
+ }
+
+ /**
+ * @return authenticated principals keyed by scheme name
+ */
+ public Map authenticatedPrincipals() {
+ return authenticatedPrincipals;
+ }
+
+ /**
+ * @return copy of request headers
+ */
+ public HttpHeadersBlock requestHeaders() {
+ return copyHeaders(requestHeaders);
+ }
+
+ /**
+ * @return copy of parsed query parameters
+ */
+ public QueryFilterParams queryParams() {
+ return copyQueryParams(queryParams);
+ }
+
+ /**
+ * @return parsed request body fields, or empty fields when no body was parsed
+ */
+ public ApiBodyFields parsedRequestBody() {
+ return parsedRequestBody;
+ }
+
+ /**
+ * @return raw request body text, or an empty string when no body was supplied
+ */
+ public String rawRequestBody() {
+ return rawRequestBody;
+ }
+
+ /**
+ * @return active API configuration
+ */
+ public ThingifierApiConfig apiConfig() {
+ return apiConfig;
+ }
+
+ private HttpHeadersBlock copyHeaders(final HttpHeadersBlock source) {
+ HttpHeadersBlock copy = new HttpHeadersBlock();
+ if (source != null) {
+ copy.putAll(source);
+ }
+ return copy;
+ }
+
+ private QueryFilterParams copyQueryParams(final QueryFilterParams source) {
+ QueryFilterParams copy = new QueryFilterParams();
+ if (source != null) {
+ copy.addAll(source);
+ }
+ return copy;
+ }
+
+ private String normalizedPublicPath(final String path) {
+ if (path == null || path.isEmpty()) {
+ return "";
+ }
+ return path.startsWith("/") ? path : "/" + path;
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java
new file mode 100644
index 00000000..0d7f8a72
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java
@@ -0,0 +1,158 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+import java.util.List;
+import java.util.Optional;
+import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
+import uk.co.compendiumdev.thingifier.application.ThingCommandResult;
+import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance;
+
+/**
+ * Immutable operation outcome supplied to route operation callbacks.
+ *
+ * The result exposes stable API-level facts first, such as status and returned entity data, and
+ * uses command/query details only to recover useful affected-instance information for write
+ * operations whose response body may have been suppressed by route policy.
+ */
+public final class ThingifierApiOperationResult {
+
+ private final int statusCode;
+ private final String operationType;
+ private final ApiResponse apiResponse;
+ private final ThingCommandResult writeCommandResult;
+
+ /**
+ * Creates an operation result.
+ *
+ * @param statusCode final API status code
+ * @param operationType operation label such as READ, CREATE, UPDATE, DELETE, or QUERY
+ * @param apiResponse structured API response
+ * @param writeCommandResult write command result when available
+ */
+ public ThingifierApiOperationResult(
+ final int statusCode,
+ final String operationType,
+ final ApiResponse apiResponse,
+ final ThingCommandResult writeCommandResult) {
+ this.statusCode = statusCode;
+ this.operationType = operationType == null ? "" : operationType;
+ this.apiResponse = apiResponse;
+ this.writeCommandResult = writeCommandResult;
+ }
+
+ /**
+ * @return final API status code visible to the caller
+ */
+ public int statusCode() {
+ return statusCode;
+ }
+
+ /**
+ * @return true for 2xx and 3xx responses
+ */
+ public boolean successful() {
+ return statusCode >= 200 && statusCode < 400;
+ }
+
+ /**
+ * @return true for responses outside the success range
+ */
+ public boolean failed() {
+ return !successful();
+ }
+
+ /**
+ * @return operation label resolved by Thingifier where possible
+ */
+ public String operationType() {
+ return operationType;
+ }
+
+ /**
+ * @return true when the operation created a new instance
+ */
+ public boolean created() {
+ return (writeCommandResult != null && writeCommandResult.createdInstance())
+ || statusCode == 201;
+ }
+
+ /**
+ * @return true when the operation appears to update an existing instance
+ */
+ public boolean updated() {
+ return successful()
+ && !created()
+ && (operationType.equals("UPDATE")
+ || operationType.equals("REPLACE")
+ || operationType.equals("PATCH")
+ || operationType.equals("UPDATE_CONNECTED"));
+ }
+
+ /**
+ * @return true when the operation appears to delete or disconnect a resource
+ */
+ public boolean deleted() {
+ return successful()
+ && (operationType.equals("DELETE") || operationType.equals("DISCONNECT"));
+ }
+
+ /**
+ * Reports whether a single affected or returned instance is available.
+ *
+ * @return true when {@link #singleInstance()} can be called safely
+ */
+ public boolean hasSingleInstance() {
+ return maybeSingleInstance().isPresent();
+ }
+
+ /**
+ * Returns the single affected or returned instance.
+ *
+ * @return single instance
+ * @throws IllegalStateException when no single instance is available
+ */
+ public EntityInstance singleInstance() {
+ return maybeSingleInstance()
+ .orElseThrow(() -> new IllegalStateException("operation has no single instance"));
+ }
+
+ /**
+ * Returns the single affected or returned instance when available.
+ *
+ * @return optional single instance
+ */
+ public Optional maybeSingleInstance() {
+ if (apiResponse != null && apiResponse.hasReturnedInstance()) {
+ return Optional.of(apiResponse.getReturnedInstance());
+ }
+ if (writeCommandResult != null && writeCommandResult.getInstance() != null) {
+ return Optional.of(writeCommandResult.getInstance());
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * @return true when the response contains a collection of instances
+ */
+ public boolean hasInstanceCollection() {
+ return apiResponse != null && apiResponse.isCollection();
+ }
+
+ /**
+ * Returns the instance collection from the response.
+ *
+ * @return immutable copy of returned instances, or an empty list
+ */
+ public List instanceCollection() {
+ if (!hasInstanceCollection()) {
+ return List.of();
+ }
+ return List.copyOf(apiResponse.getReturnedInstanceCollection());
+ }
+
+ /**
+ * @return structured API response after route response policy has been applied
+ */
+ public ApiResponse apiResponse() {
+ return apiResponse;
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java
index 50917a12..017ffd82 100644
--- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java
@@ -7,6 +7,9 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallback;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallbackDefinition;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallbackDefinition.Outcome;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
@@ -45,6 +48,7 @@ public final class ThingifierApiRouteRule {
private final List authEnforcementSchemeNames;
private final List authorizers;
private final List apiOperationValidators;
+ private final List operationCallbacks;
private RouteApiResponsePolicy successResponsePolicy;
private final Map errorResponsePolicies;
private final Map> conditionalErrorResponsePolicies;
@@ -80,6 +84,7 @@ public final class ThingifierApiRouteRule {
this.authEnforcementSchemeNames = new java.util.ArrayList<>();
this.authorizers = new java.util.ArrayList<>();
this.apiOperationValidators = new java.util.ArrayList<>();
+ this.operationCallbacks = new java.util.ArrayList<>();
this.successResponsePolicy = null;
this.errorResponsePolicies = new HashMap<>();
this.conditionalErrorResponsePolicies = new HashMap<>();
@@ -729,6 +734,136 @@ public List apiOperationValidators() {
return Collections.unmodifiableList(apiOperationValidators);
}
+ /**
+ * Registers a callback that runs after any completed outcome for this route.
+ *
+ * Operation callbacks are trusted, code-only application side effects. They run after
+ * Thingifier has created and route-shaped an {@link
+ * uk.co.compendiumdev.thingifier.api.response.ApiResponse}, and before legacy response hooks
+ * render or override the final HTTP response.
+ *
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterOperation(
+ final ThingifierApiOperationCallback callback) {
+ return afterOperation(defaultCallbackName("after-operation"), callback);
+ }
+
+ /**
+ * Registers a named callback that runs after any completed outcome for this route.
+ *
+ * @param name stable callback name used in diagnostics
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterOperation(
+ final String name, final ThingifierApiOperationCallback callback) {
+ return addOperationCallback(name, Outcome.ANY, null, callback);
+ }
+
+ /**
+ * Registers a callback that runs only for successful route outcomes.
+ *
+ *
A successful outcome is based on the final route-shaped status code in the 2xx or 3xx
+ * range.
+ *
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterSuccessfulOperation(
+ final ThingifierApiOperationCallback callback) {
+ return afterSuccessfulOperation(
+ defaultCallbackName("after-successful-operation"), callback);
+ }
+
+ /**
+ * Registers a named callback that runs only for successful route outcomes.
+ *
+ * @param name stable callback name used in diagnostics
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterSuccessfulOperation(
+ final String name, final ThingifierApiOperationCallback callback) {
+ return addOperationCallback(name, Outcome.SUCCESS, null, callback);
+ }
+
+ /**
+ * Registers a callback that runs only for failed route outcomes.
+ *
+ *
Use this for route-specific failure observation. It is not a replacement for response
+ * policies; callbacks should perform application side effects rather than shape response
+ * bodies.
+ *
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterFailedOperation(
+ final ThingifierApiOperationCallback callback) {
+ return afterFailedOperation(defaultCallbackName("after-failed-operation"), callback);
+ }
+
+ /**
+ * Registers a named callback that runs only for failed route outcomes.
+ *
+ * @param name stable callback name used in diagnostics
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterFailedOperation(
+ final String name, final ThingifierApiOperationCallback callback) {
+ return addOperationCallback(name, Outcome.FAILURE, null, callback);
+ }
+
+ /**
+ * Registers a callback that runs only when the final status code matches.
+ *
+ * @param statusCode final API status code to match
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterStatus(
+ final int statusCode, final ThingifierApiOperationCallback callback) {
+ return afterStatus(defaultCallbackName("after-status-" + statusCode), statusCode, callback);
+ }
+
+ /**
+ * Registers a named callback that runs only when the final status code matches.
+ *
+ * @param name stable callback name used in diagnostics
+ * @param statusCode final API status code to match
+ * @param callback callback to run
+ * @return callback registration for optional failure-policy configuration
+ */
+ public ThingifierApiOperationCallbackDefinition afterStatus(
+ final String name,
+ final int statusCode,
+ final ThingifierApiOperationCallback callback) {
+ return addOperationCallback(name, Outcome.STATUS, statusCode, callback);
+ }
+
+ /**
+ * Reports whether this route has operation callbacks.
+ *
+ * @return true when callbacks are registered
+ */
+ public boolean hasOperationCallbacks() {
+ return !operationCallbacks.isEmpty();
+ }
+
+ /**
+ * Returns operation callbacks in declaration order.
+ *
+ *
Callbacks are runtime-only and intentionally absent from YAML export/import and public
+ * OpenAPI because Java functions cannot safely round-trip through those formats.
+ *
+ * @return immutable callback registrations
+ */
+ public List operationCallbacks() {
+ return Collections.unmodifiableList(operationCallbacks);
+ }
+
/**
* Configures response shaping for non-error responses returned by this route.
*
@@ -1191,6 +1326,22 @@ private List normalizedSchemeNames(final String... schemeNames) {
return normalizedSchemeNames;
}
+ private ThingifierApiOperationCallbackDefinition addOperationCallback(
+ final String name,
+ final Outcome outcome,
+ final Integer statusCode,
+ final ThingifierApiOperationCallback callback) {
+ final ThingifierApiOperationCallbackDefinition definition =
+ new ThingifierApiOperationCallbackDefinition(
+ this, name, outcome, statusCode, callback);
+ operationCallbacks.add(definition);
+ return definition;
+ }
+
+ private String defaultCallbackName(final String prefix) {
+ return prefix + "-" + (operationCallbacks.size() + 1);
+ }
+
private String requireText(final String value, final String label) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(label + " is required");
diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java
new file mode 100644
index 00000000..91c02494
--- /dev/null
+++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java
@@ -0,0 +1,323 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.UPDATE;
+import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.AUTO_INCREMENT;
+import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import uk.co.compendiumdev.thingifier.Thingifier;
+import uk.co.compendiumdev.thingifier.adapter.http.messagehooks.HttpApiResponseHook;
+import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
+import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest;
+import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse;
+import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi;
+import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser;
+import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock;
+import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
+import uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy;
+import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticationResult;
+import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule;
+import uk.co.compendiumdev.thingifier.core.EntityRelModel;
+import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition;
+import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field;
+import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance;
+import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft;
+
+class RouteOperationCallbackTest {
+
+ @Test
+ void afterSuccessfulOperationReceivesFixedRouteUpdateContextAndInstance() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "old");
+ final AtomicReference seenContext = new AtomicReference<>();
+ final AtomicReference seenResult = new AtomicReference<>();
+ postSecretNoteRoute(thingifier)
+ .afterSuccessfulOperation(
+ "sync-note",
+ (context, result) -> {
+ seenContext.set(context);
+ seenResult.set(result);
+ });
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier)
+ .post(
+ jsonPost("/secret/note", "{\"text\":\"new\"}")
+ .addHeader("X-Trace", "trace-1"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("/secret/note", seenContext.get().publicPath());
+ Assertions.assertEquals("/secret/note", seenContext.get().matchedRoutePattern());
+ Assertions.assertEquals("secretnote", seenContext.get().targetEntityName().orElseThrow());
+ Assertions.assertEquals("note", seenContext.get().targetIdentifier().orElseThrow());
+ Assertions.assertEquals(
+ EntityRelModel.DEFAULT_DATABASE_NAME, seenContext.get().dataScopeName());
+ Assertions.assertEquals("trace-1", seenContext.get().requestHeaders().get("X-Trace"));
+ Assertions.assertEquals("{\"text\":\"new\"}", seenContext.get().rawRequestBody());
+ Assertions.assertEquals(200, seenResult.get().statusCode());
+ Assertions.assertTrue(seenResult.get().successful());
+ Assertions.assertTrue(seenResult.get().updated());
+ Assertions.assertEquals(
+ "new", seenResult.get().singleInstance().getFieldValue("text").asString());
+ }
+
+ @Test
+ void callbackReceivesAuthSelectedDataScopeAndPrincipal() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, "tenant-one", "note", "tenant-old");
+ thingifier
+ .apiSpec()
+ .authenticator(
+ "tenantToken",
+ context ->
+ ThingifierApiAuthenticationResult.authenticated("tenant-principal")
+ .useDataScope(
+ "tenant-one",
+ DataScopeCreationPolicy.USE_EXISTING_ONLY));
+ final AtomicReference seenContext = new AtomicReference<>();
+ postSecretNoteRoute(thingifier)
+ .secureWithBearerAuth("tenantToken")
+ .afterSuccessfulOperation(
+ "capture-tenant", (context, result) -> seenContext.set(context));
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier)
+ .post(
+ jsonPost("/secret/note", "{\"text\":\"tenant-new\"}")
+ .addHeader("Authorization", "Bearer valid-token"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("tenant-one", seenContext.get().dataScopeName());
+ Assertions.assertSame(thingifier.getStore("tenant-one"), seenContext.get().store());
+ Assertions.assertEquals("tenant-principal", seenContext.get().authenticatedPrincipal());
+ Assertions.assertEquals(
+ "tenant-new",
+ secretNote(thingifier, "tenant-one", "note").getFieldValue("text").asString());
+ }
+
+ @Test
+ void afterSuccessfulOperationDoesNotRunForValidationFailure() {
+ final Thingifier thingifier = todoModel();
+ final AtomicInteger callbackCount = new AtomicInteger();
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.POST, "/todos")
+ .afterSuccessfulOperation((context, result) -> callbackCount.incrementAndGet());
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).post(jsonPost("/todos", "{}"));
+
+ Assertions.assertEquals(422, response.getStatusCode());
+ Assertions.assertEquals(0, callbackCount.get());
+ }
+
+ @Test
+ void afterFailedOperationRunsForValidationFailure() {
+ final Thingifier thingifier = todoModel();
+ final AtomicReference seenResult = new AtomicReference<>();
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.POST, "/todos")
+ .afterFailedOperation(
+ "capture-failure", (context, result) -> seenResult.set(result));
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).post(jsonPost("/todos", "{}"));
+
+ Assertions.assertEquals(422, response.getStatusCode());
+ Assertions.assertEquals(422, seenResult.get().statusCode());
+ Assertions.assertTrue(seenResult.get().failed());
+ }
+
+ @Test
+ void statusCallbackRunsOnlyForMatchingFinalStatus() {
+ final Thingifier thingifier = secretModel();
+ final AtomicInteger callbackCount = new AtomicInteger();
+ getSecretNoteRoute(thingifier)
+ .afterStatus(404, (context, result) -> callbackCount.incrementAndGet());
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note"));
+
+ Assertions.assertEquals(404, response.getStatusCode());
+ Assertions.assertEquals(1, callbackCount.get());
+ }
+
+ @Test
+ void multipleCallbacksRunInDeclarationOrder() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible");
+ final List calls = new ArrayList<>();
+ final ThingifierApiRouteRule route = getSecretNoteRoute(thingifier);
+ route.afterOperation("first", (context, result) -> calls.add("first"));
+ route.afterStatus(200, (context, result) -> calls.add("second"));
+ route.afterSuccessfulOperation("third", (context, result) -> calls.add("third"));
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals(List.of("first", "second", "third"), calls);
+ }
+
+ @Test
+ void callbackRunsBeforeLegacyResponseHook() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible");
+ final List calls = new ArrayList<>();
+ getSecretNoteRoute(thingifier)
+ .afterSuccessfulOperation("callback", (context, result) -> calls.add("callback"));
+ final HttpApiResponseHook responseHook =
+ (request, response, config) -> {
+ calls.add("response hook");
+ return null;
+ };
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier, null, List.of(responseHook))
+ .get(jsonRequest("/secret/note"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals(List.of("callback", "response hook"), calls);
+ }
+
+ @Test
+ void throwingCallbackFailsRequestByDefault() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible");
+ getSecretNoteRoute(thingifier)
+ .afterSuccessfulOperation(
+ "explode",
+ (context, result) -> {
+ throw new IllegalStateException("boom");
+ });
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note"));
+
+ Assertions.assertEquals(500, response.getStatusCode());
+ Assertions.assertTrue(response.getBody().contains("explode"));
+ }
+
+ @Test
+ void logAndContinueCallbackFailurePreservesOriginalResponse() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible");
+ getSecretNoteRoute(thingifier)
+ .afterSuccessfulOperation(
+ "best-effort",
+ (context, result) -> {
+ throw new IllegalStateException("boom");
+ })
+ .onCallbackFailure(CallbackFailurePolicy.LOG_AND_CONTINUE);
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertTrue(response.getBody().contains("visible"));
+ }
+
+ @Test
+ void directApiInvokesRouteCallbacks() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "old");
+ final AtomicReference seenResult = new AtomicReference<>();
+ postSecretNoteRoute(thingifier)
+ .afterSuccessfulOperation("direct", (context, result) -> seenResult.set(result));
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "secret/note",
+ parser(thingifier, "{\"text\":\"new\"}"),
+ new HttpHeadersBlock());
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals(
+ "new", seenResult.get().singleInstance().getFieldValue("text").asString());
+ }
+
+ private ThingifierApiRouteRule getSecretNoteRoute(final Thingifier thingifier) {
+ return thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/secret/note")
+ .mapsToEntity("secretnote")
+ .withFixedIdentifier("note");
+ }
+
+ private ThingifierApiRouteRule postSecretNoteRoute(final Thingifier thingifier) {
+ return thingifier
+ .apiSpec()
+ .route(RoutingVerb.POST, "/secret/note")
+ .mapsToEntity("secretnote")
+ .withFixedIdentifier("note")
+ .entityCan(UPDATE);
+ }
+
+ private Thingifier secretModel() {
+ final Thingifier thingifier = new Thingifier();
+ final EntityDefinition note = thingifier.defineThing("secretnote", "secretnotes", 10);
+ note.addAsPrimaryKeyField(Field.is("id", STRING));
+ note.addField(Field.is("text", STRING));
+ return thingifier;
+ }
+
+ private Thingifier todoModel() {
+ final Thingifier thingifier = new Thingifier();
+ final EntityDefinition todo = thingifier.defineThing("todo", "todos", 10);
+ todo.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT));
+ todo.addField(Field.is("title", STRING).makeMandatory());
+ return thingifier;
+ }
+
+ private EntityInstance createSecretNote(
+ final Thingifier thingifier,
+ final String dataScopeName,
+ final String id,
+ final String text) {
+ thingifier.getERmodel().createInstanceDatabaseIfNotExisting(dataScopeName);
+ final EntityDefinition note = thingifier.getDefinitionNamed("secretnote");
+ return thingifier
+ .getStore(dataScopeName)
+ .entities()
+ .create(
+ EntityInstanceDraft.forEntity(note)
+ .withField("id", id)
+ .withField("text", text));
+ }
+
+ private EntityInstance secretNote(
+ final Thingifier thingifier, final String dataScopeName, final String id) {
+ return thingifier
+ .getStore(dataScopeName)
+ .entityQueries()
+ .findByPrimaryKey(thingifier.getDefinitionNamed("secretnote"), id);
+ }
+
+ private HttpApiRequest jsonRequest(final String path) {
+ return new HttpApiRequest(path).addHeader("Accept", "application/json");
+ }
+
+ private HttpApiRequest jsonPost(final String path, final String body) {
+ return new HttpApiRequest(path)
+ .setVerb("POST")
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .setBody(body);
+ }
+
+ private BodyParser parser(final Thingifier thingifier, final String body) {
+ return new BodyParser(
+ new HttpApiRequest("/request")
+ .addHeader("Content-Type", "application/json")
+ .setBody(body),
+ thingifier.getThingNames());
+ }
+}