From 5937418cad3d8cab949e1958436c355ba70380b6 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 26 Aug 2026 11:15:14 +0100 Subject: [PATCH] Add route after-response callbacks --- .../RelationshipVectorDefinition.java | 2 +- pom.xml | 34 ++ .../RouteAfterResponseCallbackApplier.java | 99 ++++++ .../RouteCallbackContextFactory.java | 330 ++++++++++++++++++ .../RouteOperationCallbackApplier.java | 280 +-------------- .../callbacks/ThingifierApiFinalResponse.java | 116 ++++++ .../ThingifierApiResponseCallback.java | 21 ++ ...ingifierApiResponseCallbackDefinition.java | 50 +++ .../api/http/ThingifierHttpApi.java | 38 +- .../api/spec/ThingifierApiRouteRule.java | 56 +++ .../RouteAfterResponseCallbackTest.java | 228 ++++++++++++ 11 files changed, 981 insertions(+), 273 deletions(-) create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAfterResponseCallbackApplier.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiFinalResponse.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallback.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiResponseCallbackDefinition.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteAfterResponseCallbackTest.java 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"); + } +}