diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/relationship/RelationshipVectorDefinition.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/relationship/RelationshipVectorDefinition.java
index cd92798c..48f3fad0 100644
--- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/relationship/RelationshipVectorDefinition.java
+++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/relationship/RelationshipVectorDefinition.java
@@ -9,7 +9,7 @@
* A relationshipVector is the definition of the variant of relationship from one thing to another
* e.g. a specific a -> b relationship might have a different name from the main relationships
*
- *
task <- estimates /estimate of-> estimate
+ *
{@code task <- estimates /estimate of-> estimate}
*
*
task to estimate would be called 'estimates' and would be 1(o):M i.e. 1 task can have 0 to
* many estimates estimate to task would be called 'estimate-of' and would be 1:1 an estimate must
diff --git a/pom.xml b/pom.xml
index d5c0caf2..76999b5a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -30,10 +30,44 @@
3.28.0
3.8.0
1.35.0
+ 3.4.0
+ 3.12.0
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ ${maven-source-plugin.version}
+
+
+ attach-sources
+ package
+
+ jar-no-fork
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ ${maven-javadoc-plugin.version}
+
+ none
+ true
+
+
+
+ attach-javadocs
+ package
+
+ jar
+
+
+
+
com.diffplug.spotless
spotless-maven-plugin
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAfterResponseCallbackApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAfterResponseCallbackApplier.java
new file mode 100644
index 00000000..28d4dc75
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAfterResponseCallbackApplier.java
@@ -0,0 +1,99 @@
+package uk.co.compendiumdev.thingifier.adapter.http.apihandlers;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+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.ThingifierApiFinalResponse;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationContext;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiResponseCallbackDefinition;
+import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
+import uk.co.compendiumdev.thingifier.api.http.ApiRequestEnvelope;
+import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse;
+import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext;
+import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule;
+
+/**
+ * Runs route-level final-response callbacks after HTTP rendering and content negotiation.
+ *
+ * Callbacks are observational in v1. If application code throws, the exception is logged and the
+ * already-produced response is preserved so metrics or challenge-completion code cannot
+ * accidentally corrupt the HTTP outcome.
+ */
+public final class RouteAfterResponseCallbackApplier {
+
+ private static final Logger LOGGER =
+ Logger.getLogger(RouteAfterResponseCallbackApplier.class.getName());
+
+ private final RouteCallbackContextFactory contextFactory;
+
+ /**
+ * Creates an applier for the current API runtime.
+ *
+ * @param runtime runtime used to find route rules and route metadata
+ */
+ public RouteAfterResponseCallbackApplier(final ThingifierApiRuntime runtime) {
+ this.contextFactory = new RouteCallbackContextFactory(runtime);
+ }
+
+ /**
+ * Runs final-response callbacks registered on the matched route rule.
+ *
+ * @param verb route verb for route-rule lookup
+ * @param publicPath public request path
+ * @param response final rendered HTTP response
+ * @param requestContext active request context, or null to derive it from lifecycle
+ * @param lifecycle lifecycle context when processing an HTTP/lifecycle request
+ * @param request parsed request envelope when available
+ * @param bodyAvailable false when response body access should not be exposed
+ * @return original final response
+ */
+ public HttpApiResponse apply(
+ final RoutingVerb verb,
+ final String publicPath,
+ final HttpApiResponse response,
+ final ThingifierRequestContext requestContext,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request,
+ final boolean bodyAvailable) {
+ if (response == null) {
+ return null;
+ }
+
+ final ThingifierApiRouteRule routeRule =
+ contextFactory.routeRuleFor(verb, publicPath).orElse(null);
+ if (routeRule == null || !routeRule.hasResponseCallbacks()) {
+ return response;
+ }
+
+ final ThingRoute route = contextFactory.route(lifecycle, verb, publicPath);
+ final ThingifierApiOperationContext context =
+ contextFactory.contextFor(
+ verb, publicPath, route, routeRule, requestContext, lifecycle, request);
+ final ThingifierApiFinalResponse finalResponse =
+ ThingifierApiFinalResponse.from(response, bodyAvailable);
+
+ for (ThingifierApiResponseCallbackDefinition definition : routeRule.responseCallbacks()) {
+ try {
+ definition.callback().run(context, finalResponse);
+ } catch (Exception exception) {
+ logCallbackFailure(definition, verb, publicPath, finalResponse, exception);
+ }
+ }
+ return response;
+ }
+
+ private void logCallbackFailure(
+ final ThingifierApiResponseCallbackDefinition definition,
+ final RoutingVerb verb,
+ final String publicPath,
+ final ThingifierApiFinalResponse response,
+ final Exception exception) {
+ LOGGER.log(
+ Level.SEVERE,
+ String.format(
+ "Route final response callback '%s' failed for %s %s after status %d",
+ definition.name(), verb, publicPath, response.statusCode()),
+ exception);
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java
new file mode 100644
index 00000000..50c9413e
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java
@@ -0,0 +1,330 @@
+package uk.co.compendiumdev.thingifier.adapter.http.apihandlers;
+
+import java.util.Optional;
+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.ThingifierApiOperationContext;
+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.spec.ThingifierApiRouteRule;
+import uk.co.compendiumdev.thingifier.application.command.ThingWriteCommand;
+import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec;
+import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams;
+
+/**
+ * Builds the shared route callback context used by route operation and final-response callbacks.
+ *
+ *
Both callback families need the same trusted route, fixed-identifier, auth principal, and
+ * data-scope facts. Keeping that construction here prevents each callback phase from rediscovering
+ * route details differently.
+ */
+final class RouteCallbackContextFactory {
+
+ private final ThingifierApiRuntime runtime;
+
+ /**
+ * Creates a context factory for the current API runtime.
+ *
+ * @param runtime runtime used to find route rules and route metadata
+ */
+ RouteCallbackContextFactory(final ThingifierApiRuntime runtime) {
+ this.runtime = runtime;
+ }
+
+ /**
+ * Finds the route rule for a public route path.
+ *
+ * @param verb route verb
+ * @param publicPath public request path
+ * @return matching route rule, if any
+ */
+ Optional routeRuleFor(final RoutingVerb verb, final String publicPath) {
+ return runtime.apiSpec()
+ .ruleFor(verb, publicPath, runtime.apiConfig().getApiEndPointPrefix());
+ }
+
+ /**
+ * Resolves the generated route used by callback contexts.
+ *
+ * @param lifecycle lifecycle context when processing an HTTP/lifecycle request
+ * @param verb route verb
+ * @param publicPath public request path
+ * @return resolved route
+ */
+ ThingRoute route(
+ final ThingifierApiLifecycleContext lifecycle,
+ final RoutingVerb verb,
+ final String publicPath) {
+ return lifecycle == null ? runtime.routeFor(verb, publicPath) : lifecycle.route();
+ }
+
+ /**
+ * Creates the shared immutable callback context.
+ *
+ * @param verb route verb
+ * @param publicPath public request path
+ * @param route resolved generated route
+ * @param routeRule matched route rule that owns the callback
+ * @param requestContext active request context, or null to derive it from lifecycle
+ * @param lifecycle lifecycle context when processing an HTTP/lifecycle request
+ * @param request parsed request envelope when available
+ * @return callback context
+ */
+ ThingifierApiOperationContext contextFor(
+ final RoutingVerb verb,
+ final String publicPath,
+ final ThingRoute route,
+ final ThingifierApiRouteRule routeRule,
+ final ThingifierRequestContext requestContext,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request) {
+ final ThingifierRequestContext activeContext =
+ requestContext != null
+ ? requestContext
+ : (lifecycle == null ? null : lifecycle.requestContext());
+ 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),
+ activeContext == null ? null : activeContext.dataScopeName(),
+ activeContext == null ? null : activeContext.store(),
+ activeContext == null
+ ? java.util.Map.of()
+ : activeContext.authenticatedPrincipals(),
+ requestHeaders(activeContext, lifecycle, request),
+ queryParams(lifecycle, request),
+ bodyFields(lifecycle, request),
+ rawBody(lifecycle, request),
+ runtime.apiConfig());
+ }
+
+ /**
+ * Resolves the operation type label for route operation callbacks.
+ *
+ * @param verb route verb
+ * @param lifecycle lifecycle context when available
+ * @return operation type label
+ */
+ 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 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 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;
+ }
+ }
+}
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
index e01010ae..bb79de71 100644
--- 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
@@ -1,12 +1,7 @@
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;
@@ -16,13 +11,9 @@
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.api.spec.ThingifierApiRouteRule;
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.
@@ -36,7 +27,7 @@ public final class RouteOperationCallbackApplier {
private static final Logger LOGGER =
Logger.getLogger(RouteOperationCallbackApplier.class.getName());
- private final ThingifierApiRuntime runtime;
+ private final RouteCallbackContextFactory contextFactory;
/**
* Creates an applier for the current API runtime.
@@ -44,7 +35,7 @@ public final class RouteOperationCallbackApplier {
* @param runtime runtime used to find route rules and route metadata
*/
public RouteOperationCallbackApplier(final ThingifierApiRuntime runtime) {
- this.runtime = runtime;
+ this.contextFactory = new RouteCallbackContextFactory(runtime);
}
/**
@@ -69,17 +60,18 @@ public ApiResponse apply(
return null;
}
- final uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule routeRule =
- routeRuleFor(verb, publicPath).orElse(null);
+ final ThingifierApiRouteRule routeRule =
+ contextFactory.routeRuleFor(verb, publicPath).orElse(null);
if (routeRule == null || !routeRule.hasOperationCallbacks()) {
return response;
}
- final ThingRoute route = route(lifecycle, verb, publicPath);
+ final ThingRoute route = contextFactory.route(lifecycle, verb, publicPath);
final ThingifierApiOperationContext context =
- contextFor(verb, publicPath, route, routeRule, requestContext, lifecycle, request);
+ contextFactory.contextFor(
+ verb, publicPath, route, routeRule, requestContext, lifecycle, request);
final ThingifierApiOperationResult result =
- resultFor(response, lifecycle, operationTypeFor(verb, lifecycle));
+ resultFor(response, lifecycle, contextFactory.operationTypeFor(verb, lifecycle));
for (ThingifierApiOperationCallbackDefinition definition : routeRule.operationCallbacks()) {
if (!definition.matches(result)) {
@@ -98,50 +90,6 @@ public ApiResponse apply(
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,
@@ -152,216 +100,6 @@ private ThingifierApiOperationResult resultFor(
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,
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiFinalResponse.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiFinalResponse.java
new file mode 100644
index 00000000..46b744f0
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiFinalResponse.java
@@ -0,0 +1,116 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+import java.util.Optional;
+import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse;
+import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock;
+import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
+
+/**
+ * Immutable final HTTP response details supplied to route {@code afterResponse} callbacks.
+ *
+ * This object is created after Thingifier has applied route response policies, response views,
+ * and HTTP content negotiation. It deliberately exposes the HTTP response as observed by the
+ * caller, not just the internal structured {@link ApiResponse}.
+ */
+public final class ThingifierApiFinalResponse {
+
+ private final int statusCode;
+ private final String contentType;
+ private final HttpHeadersBlock headers;
+ private final String body;
+ private final ApiResponse apiResponse;
+
+ private ThingifierApiFinalResponse(
+ final int statusCode,
+ final String contentType,
+ final HttpHeadersBlock headers,
+ final String body,
+ final ApiResponse apiResponse) {
+ this.statusCode = statusCode;
+ this.contentType = contentType == null ? "" : contentType;
+ this.headers = copyHeaders(headers);
+ this.body = body;
+ this.apiResponse = apiResponse;
+ }
+
+ /**
+ * Creates a final response view from Thingifier's rendered HTTP response.
+ *
+ * @param response rendered HTTP response
+ * @param bodyAvailable false when the body should not be exposed, such as HEAD responses
+ * @return immutable final response details
+ */
+ public static ThingifierApiFinalResponse from(
+ final HttpApiResponse response, final boolean bodyAvailable) {
+ if (response == null) {
+ throw new IllegalArgumentException("response is required");
+ }
+ final HttpHeadersBlock headers = response.getHeaders();
+ final String body = bodyAvailable ? response.getBody() : null;
+ return new ThingifierApiFinalResponse(
+ response.getStatusCode(),
+ headers.get("Content-Type"),
+ headers,
+ body,
+ response.apiResponse());
+ }
+
+ /**
+ * Returns the final HTTP status code.
+ *
+ * @return HTTP status code visible to the caller
+ */
+ public int statusCode() {
+ return statusCode;
+ }
+
+ /**
+ * Returns the final HTTP {@code Content-Type} header after content negotiation.
+ *
+ *
This is the final outbound HTTP header value, not the internal {@link ApiResponse}'s
+ * preferred or structured representation.
+ *
+ * @return final HTTP content type, or an empty string when no content type is present
+ */
+ public String contentType() {
+ return contentType;
+ }
+
+ /**
+ * Returns a copy of the final HTTP response headers.
+ *
+ * @return response headers
+ */
+ public HttpHeadersBlock headers() {
+ return copyHeaders(headers);
+ }
+
+ /**
+ * Returns the final response body when Thingifier has one available for callback inspection.
+ *
+ *
The body may be unavailable for HEAD, streaming, or other no-body responses. When the body
+ * is available but empty, the optional contains an empty string.
+ *
+ * @return optional response body
+ */
+ public Optional body() {
+ return Optional.ofNullable(body);
+ }
+
+ /**
+ * Returns the structured API response that was rendered into the final HTTP response.
+ *
+ * @return structured API response
+ */
+ public ApiResponse apiResponse() {
+ return apiResponse;
+ }
+
+ private static HttpHeadersBlock copyHeaders(final HttpHeadersBlock original) {
+ final HttpHeadersBlock copy = new HttpHeadersBlock();
+ if (original != null) {
+ copy.putAll(original);
+ }
+ return copy;
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallback.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallback.java
new file mode 100644
index 00000000..1d9ab976
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallback.java
@@ -0,0 +1,21 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+/**
+ * Trusted application callback invoked after a route has produced its final HTTP response.
+ *
+ * Use final response callbacks for observational side effects that need the negotiated HTTP
+ * outcome, such as audit events, challenge completion, or response-based metrics. The callback is
+ * read-only in v1; response mutation belongs in route response policies or legacy HTTP response
+ * hooks.
+ */
+@FunctionalInterface
+public interface ThingifierApiResponseCallback {
+
+ /**
+ * Runs the application callback for one final route response.
+ *
+ * @param context immutable route, request, auth, and data-scope information
+ * @param response immutable final HTTP response details
+ */
+ void run(ThingifierApiOperationContext context, ThingifierApiFinalResponse response);
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallbackDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallbackDefinition.java
new file mode 100644
index 00000000..167f386a
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallbackDefinition.java
@@ -0,0 +1,50 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+/**
+ * Runtime-only registration for one route final-response 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 when a callback throws.
+ */
+public final class ThingifierApiResponseCallbackDefinition {
+
+ private final String name;
+ private final ThingifierApiResponseCallback callback;
+
+ /**
+ * Creates a final-response callback registration.
+ *
+ * @param name stable diagnostic name
+ * @param callback trusted application callback
+ */
+ public ThingifierApiResponseCallbackDefinition(
+ final String name, final ThingifierApiResponseCallback callback) {
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException("callback name is required");
+ }
+ if (callback == null) {
+ throw new IllegalArgumentException("callback is required");
+ }
+ this.name = name.trim();
+ this.callback = callback;
+ }
+
+ /**
+ * Returns the stable diagnostic callback name.
+ *
+ * @return callback name
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Returns the application callback.
+ *
+ * @return trusted callback
+ */
+ public ThingifierApiResponseCallback callback() {
+ return callback;
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java
index f24679a6..38903e93 100644
--- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java
@@ -11,6 +11,7 @@
import uk.co.compendiumdev.thingifier.Thingifier;
import uk.co.compendiumdev.thingifier.adapter.hooks.ScopedHook;
import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.DefaultThingifierApiRuntime;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteAfterResponseCallbackApplier;
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.ScopedSessionPolicyApplier;
@@ -222,6 +223,7 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver
HttpApiResponse httpResponse = runTheHttpApiRequestHooksOn(request, effectiveVerb);
ThingifierApiLifecycleContext lifecycle = null;
+ ApiRequestEnvelope parsedEnvelope = null;
if (httpResponse == null && supportsLifecycle(effectiveVerb)) {
lifecycle = lifecycleContextFor(request, effectiveVerb);
lifecycleHooks.runRouteMatchedHooks(lifecycle);
@@ -275,7 +277,7 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver
// no httpResponse generated after validation so it is not in error
if (httpResponse == null) {
if (lifecycle != null) {
- ApiRequestEnvelope parsedEnvelope =
+ parsedEnvelope =
ApiRequestEnvelope.from(
request,
effectiveVerb,
@@ -294,6 +296,10 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver
}
}
+ httpResponse =
+ runRouteAfterResponseCallbacksOn(
+ request, httpResponse, effectiveVerb, lifecycle, parsedEnvelope);
+
// run any post processing response hooks
return runTheHttpApiResponseHooksOn(request, httpResponse, effectiveVerb);
}
@@ -384,6 +390,36 @@ private HttpApiResponse httpResponseFor(
return httpResponse;
}
+ /**
+ * Runs route-level final-response callbacks after HTTP rendering and before legacy hooks.
+ *
+ * @param request HTTP API request
+ * @param response rendered HTTP API response
+ * @param effectiveVerb verb after method override handling
+ * @param lifecycle lifecycle context for matched generated routes
+ * @param parsedEnvelope parsed request envelope when parsing reached that phase
+ * @return original rendered response
+ */
+ private HttpApiResponse runRouteAfterResponseCallbacksOn(
+ final HttpApiRequest request,
+ final HttpApiResponse response,
+ final HttpVerb effectiveVerb,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope parsedEnvelope) {
+ if (response == null || lifecycle == null) {
+ return response;
+ }
+ return new RouteAfterResponseCallbackApplier(new DefaultThingifierApiRuntime(thingifier))
+ .apply(
+ routingVerbFor(effectiveVerb),
+ request.getPath(),
+ response,
+ null,
+ lifecycle,
+ parsedEnvelope,
+ effectiveVerb != HttpVerb.HEAD);
+ }
+
/**
* Reports whether API spec disables the matched generated route.
*
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 9d86d6c9..575b327c 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
@@ -10,6 +10,8 @@
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.callbacks.ThingifierApiResponseCallback;
+import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiResponseCallbackDefinition;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
@@ -51,6 +53,7 @@ public final class ThingifierApiRouteRule {
private final List authorizers;
private final List apiOperationValidators;
private final List operationCallbacks;
+ private final List responseCallbacks;
private RouteApiResponsePolicy successResponsePolicy;
private final Map errorResponsePolicies;
private final Map> conditionalErrorResponsePolicies;
@@ -89,6 +92,7 @@ public final class ThingifierApiRouteRule {
this.authorizers = new java.util.ArrayList<>();
this.apiOperationValidators = new java.util.ArrayList<>();
this.operationCallbacks = new java.util.ArrayList<>();
+ this.responseCallbacks = new java.util.ArrayList<>();
this.successResponsePolicy = null;
this.errorResponsePolicies = new HashMap<>();
this.conditionalErrorResponsePolicies = new HashMap<>();
@@ -965,6 +969,54 @@ public List operationCallbacks() {
return Collections.unmodifiableList(operationCallbacks);
}
+ /**
+ * Registers a read-only callback that runs after the final HTTP response has been rendered.
+ *
+ * Final-response callbacks run after route response policies, response views, and content
+ * negotiation have produced the outbound HTTP response. They are intended for observational
+ * application side effects that need final status, headers, body availability, and negotiated
+ * content type. If the callback throws, Thingifier logs the exception and preserves the already
+ * produced response.
+ *
+ * @param callback callback to run
+ * @return this route rule for continued route configuration
+ */
+ public ThingifierApiRouteRule afterResponse(final ThingifierApiResponseCallback callback) {
+ return afterResponse(defaultResponseCallbackName("after-response"), callback);
+ }
+
+ /**
+ * Registers a named read-only callback that runs after the final HTTP response has been
+ * rendered.
+ *
+ * @param name stable callback name used in diagnostics
+ * @param callback callback to run
+ * @return this route rule for continued route configuration
+ */
+ public ThingifierApiRouteRule afterResponse(
+ final String name, final ThingifierApiResponseCallback callback) {
+ responseCallbacks.add(new ThingifierApiResponseCallbackDefinition(name, callback));
+ return this;
+ }
+
+ /**
+ * Reports whether this route has final-response callbacks.
+ *
+ * @return true when callbacks are registered
+ */
+ public boolean hasResponseCallbacks() {
+ return !responseCallbacks.isEmpty();
+ }
+
+ /**
+ * Returns final-response callbacks in declaration order.
+ *
+ * @return immutable callback registrations
+ */
+ public List responseCallbacks() {
+ return Collections.unmodifiableList(responseCallbacks);
+ }
+
/**
* Configures response shaping for non-error responses returned by this route.
*
@@ -1443,6 +1495,10 @@ private String defaultCallbackName(final String prefix) {
return prefix + "-" + (operationCallbacks.size() + 1);
}
+ private String defaultResponseCallbackName(final String prefix) {
+ return prefix + "-" + (responseCallbacks.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/RouteAfterResponseCallbackTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteAfterResponseCallbackTest.java
new file mode 100644
index 00000000..f93bf400
--- /dev/null
+++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteAfterResponseCallbackTest.java
@@ -0,0 +1,228 @@
+package uk.co.compendiumdev.thingifier.api.callbacks;
+
+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.security.ThingifierApiAuthenticationResult;
+import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizationResult;
+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.EntityInstanceDraft;
+
+class RouteAfterResponseCallbackTest {
+
+ @Test
+ void afterResponseReceivesFinalNegotiatedContentType() {
+ final Thingifier thingifier = taskModelWithOneTask();
+ final AtomicReference seenResponse = new AtomicReference<>();
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/tasks")
+ .afterResponse((context, response) -> seenResponse.set(response));
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier)
+ .get(new HttpApiRequest("/tasks").addHeader("Accept", "application/xml"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("application/xml", response.getType());
+ Assertions.assertEquals("application/xml", seenResponse.get().contentType());
+ Assertions.assertEquals(
+ "application/xml", seenResponse.get().headers().get("Content-Type"));
+ Assertions.assertTrue(seenResponse.get().body().orElseThrow().contains(""));
+ Assertions.assertEquals(200, seenResponse.get().apiResponse().getStatusCode());
+ }
+
+ @Test
+ void afterResponseReceivesFixedRouteTargetDetails() {
+ final Thingifier thingifier = secretModel();
+ createSecretNote(thingifier, "note", "visible");
+ final AtomicReference seenContext = new AtomicReference<>();
+ getSecretNoteRoute(thingifier)
+ .afterResponse((context, response) -> seenContext.set(context));
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note"));
+
+ 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());
+ }
+
+ @Test
+ void afterResponseRunsForMissingCredentialAuthFailure() {
+ final Thingifier thingifier = taskModelWithOneTask();
+ final AtomicReference seenContext = new AtomicReference<>();
+ final AtomicReference seenResponse = new AtomicReference<>();
+ thingifier
+ .apiSpec()
+ .authenticator(
+ "todoToken",
+ context -> ThingifierApiAuthenticationResult.authenticated("principal"));
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/tasks")
+ .secureWithBearerAuth("todoToken")
+ .afterResponse(
+ (context, response) -> {
+ seenContext.set(context);
+ seenResponse.set(response);
+ });
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/tasks"));
+
+ Assertions.assertEquals(401, response.getStatusCode());
+ Assertions.assertEquals(401, seenResponse.get().statusCode());
+ Assertions.assertNull(seenContext.get().authenticatedPrincipal());
+ }
+
+ @Test
+ void afterResponseRunsForAuthorizerDeniedResponse() {
+ final Thingifier thingifier = taskModelWithOneTask();
+ final AtomicReference seenContext = new AtomicReference<>();
+ final AtomicReference seenResponse = new AtomicReference<>();
+ thingifier
+ .apiSpec()
+ .authenticator(
+ "todoToken",
+ context ->
+ ThingifierApiAuthenticationResult.authenticated("valid-principal"));
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/tasks")
+ .secureWithBearerAuth("todoToken")
+ .authorizeWith(context -> ThingifierApiAuthorizationResult.forbidden())
+ .afterResponse(
+ (context, response) -> {
+ seenContext.set(context);
+ seenResponse.set(response);
+ });
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier)
+ .get(
+ jsonRequest("/tasks")
+ .addHeader("Authorization", "Bearer valid-token"));
+
+ Assertions.assertEquals(403, response.getStatusCode());
+ Assertions.assertEquals(403, seenResponse.get().statusCode());
+ Assertions.assertEquals("valid-principal", seenContext.get().authenticatedPrincipal());
+ }
+
+ @Test
+ void throwingAfterResponseCallbackPreservesAlreadyRenderedResponse() {
+ final Thingifier thingifier = taskModelWithOneTask();
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/tasks")
+ .afterResponse(
+ "explode",
+ (context, response) -> {
+ throw new IllegalStateException("boom");
+ });
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/tasks"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertTrue(response.getBody().contains("Task"));
+ }
+
+ @Test
+ void afterResponseRunsBeforeLegacyHttpResponseHook() {
+ final Thingifier thingifier = taskModelWithOneTask();
+ final List calls = new ArrayList<>();
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/tasks")
+ .afterResponse((context, response) -> calls.add("after-response"));
+ final HttpApiResponseHook legacyHook =
+ (request, response, config) -> {
+ calls.add("legacy-response-hook");
+ return null;
+ };
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier, null, List.of(legacyHook))
+ .get(jsonRequest("/tasks"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals(List.of("after-response", "legacy-response-hook"), calls);
+ }
+
+ @Test
+ void afterResponseDoesNotRunForUnmatchedRoute() {
+ final Thingifier thingifier = taskModelWithOneTask();
+ final AtomicInteger callbackCount = new AtomicInteger();
+ thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/tasks")
+ .afterResponse((context, response) -> callbackCount.incrementAndGet());
+
+ final HttpApiResponse response =
+ new ThingifierHttpApi(thingifier).get(jsonRequest("/unknown"));
+
+ Assertions.assertEquals(404, response.getStatusCode());
+ Assertions.assertEquals(0, callbackCount.get());
+ }
+
+ private Thingifier taskModelWithOneTask() {
+ final Thingifier thingifier = new Thingifier();
+ final EntityDefinition task = thingifier.defineThing("task", "tasks");
+ task.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT));
+ task.addField(Field.is("title", STRING));
+ thingifier
+ .getStore(EntityRelModel.DEFAULT_DATABASE_NAME)
+ .entities()
+ .create(EntityInstanceDraft.forEntity(task).withField("title", "Task"));
+ return thingifier;
+ }
+
+ private ThingifierApiRouteRule getSecretNoteRoute(final Thingifier thingifier) {
+ return thingifier
+ .apiSpec()
+ .route(RoutingVerb.GET, "/secret/note")
+ .mapsToEntity("secretnote")
+ .withFixedIdentifier("note");
+ }
+
+ 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 void createSecretNote(final Thingifier thingifier, final String id, final String text) {
+ final EntityDefinition note = thingifier.getDefinitionNamed("secretnote");
+ thingifier
+ .getStore(EntityRelModel.DEFAULT_DATABASE_NAME)
+ .entities()
+ .create(
+ EntityInstanceDraft.forEntity(note)
+ .withField("id", id)
+ .withField("text", text));
+ }
+
+ private HttpApiRequest jsonRequest(final String path) {
+ return new HttpApiRequest(path).addHeader("Accept", "application/json");
+ }
+}