From a4bf8c13b81afe617d2bcb6d109f830371430e64 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 23 Aug 2026 22:33:54 +0100 Subject: [PATCH] Add route operation callbacks Closes #166 --- .../RouteOperationCallbackApplier.java | 387 ++++++++++++++++++ .../api/ThingifierRestAPIHandler.java | 60 ++- .../api/callbacks/CallbackFailurePolicy.java | 26 ++ .../ThingifierApiOperationCallback.java | 21 + ...ngifierApiOperationCallbackDefinition.java | 156 +++++++ .../ThingifierApiOperationContext.java | 286 +++++++++++++ .../ThingifierApiOperationResult.java | 158 +++++++ .../api/spec/ThingifierApiRouteRule.java | 151 +++++++ .../callbacks/RouteOperationCallbackTest.java | 323 +++++++++++++++ 9 files changed, 1557 insertions(+), 11 deletions(-) create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java new file mode 100644 index 00000000..e01010ae --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteOperationCallbackApplier.java @@ -0,0 +1,387 @@ +package uk.co.compendiumdev.thingifier.adapter.http.apihandlers; + +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipInstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; +import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleContext; +import uk.co.compendiumdev.thingifier.api.callbacks.CallbackFailurePolicy; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallbackDefinition; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationContext; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationResult; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.ApiRequestEnvelope; +import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.ThingCommandResult; +import uk.co.compendiumdev.thingifier.application.command.ThingWriteCommand; +import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +/** + * Runs route-level operation callbacks after Thingifier has produced a route-shaped response. + * + *

The applier is shared by direct and HTTP-backed processing. It keeps callbacks separate from + * response policies: policies shape the response first, callbacks observe the selected route/result + * and can perform trusted application side effects before legacy response hooks run. + */ +public final class RouteOperationCallbackApplier { + + private static final Logger LOGGER = + Logger.getLogger(RouteOperationCallbackApplier.class.getName()); + + private final ThingifierApiRuntime runtime; + + /** + * Creates an applier for the current API runtime. + * + * @param runtime runtime used to find route rules and route metadata + */ + public RouteOperationCallbackApplier(final ThingifierApiRuntime runtime) { + this.runtime = runtime; + } + + /** + * Runs callbacks registered on the matched route rule. + * + * @param verb route verb for route-rule lookup + * @param publicPath public request path + * @param response route-shaped API response + * @param requestContext active request context + * @param lifecycle lifecycle context when processing an HTTP/lifecycle request, otherwise null + * @param request parsed request envelope when available + * @return original response, or a callback failure response when configured to fail the request + */ + public ApiResponse apply( + final RoutingVerb verb, + final String publicPath, + final ApiResponse response, + final ThingifierRequestContext requestContext, + final ThingifierApiLifecycleContext lifecycle, + final ApiRequestEnvelope request) { + if (response == null) { + return null; + } + + final uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule routeRule = + routeRuleFor(verb, publicPath).orElse(null); + if (routeRule == null || !routeRule.hasOperationCallbacks()) { + return response; + } + + final ThingRoute route = route(lifecycle, verb, publicPath); + final ThingifierApiOperationContext context = + contextFor(verb, publicPath, route, routeRule, requestContext, lifecycle, request); + final ThingifierApiOperationResult result = + resultFor(response, lifecycle, operationTypeFor(verb, lifecycle)); + + for (ThingifierApiOperationCallbackDefinition definition : routeRule.operationCallbacks()) { + if (!definition.matches(result)) { + continue; + } + try { + definition.callback().run(context, result); + } catch (Exception exception) { + logCallbackFailure(definition, verb, publicPath, response, exception); + if (definition.failurePolicy() == CallbackFailurePolicy.FAIL_REQUEST) { + return ApiResponse.error( + 500, callbackFailureMessage(definition, verb, publicPath)); + } + } + } + return response; + } + + private Optional routeRuleFor( + final RoutingVerb verb, final String publicPath) { + return runtime.apiSpec() + .ruleFor(verb, publicPath, runtime.apiConfig().getApiEndPointPrefix()); + } + + private ThingRoute route( + final ThingifierApiLifecycleContext lifecycle, + final RoutingVerb verb, + final String publicPath) { + return lifecycle == null ? runtime.routeFor(verb, publicPath) : lifecycle.route(); + } + + private ThingifierApiOperationContext contextFor( + final RoutingVerb verb, + final String publicPath, + final ThingRoute route, + final uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule routeRule, + final ThingifierRequestContext requestContext, + final ThingifierApiLifecycleContext lifecycle, + final ApiRequestEnvelope request) { + return new ThingifierApiOperationContext( + verb, + publicPath, + route, + routeRule, + targetEntityName(route, lifecycle), + targetIdentifier(route, lifecycle), + parentEntityName(route, lifecycle), + parentIdentifier(route, lifecycle), + relationshipName(route, lifecycle), + childIdentifier(route, lifecycle), + requestContext == null ? null : requestContext.dataScopeName(), + requestContext == null ? null : requestContext.store(), + requestContext == null + ? java.util.Map.of() + : requestContext.authenticatedPrincipals(), + requestHeaders(requestContext, lifecycle, request), + queryParams(lifecycle, request), + bodyFields(lifecycle, request), + rawBody(lifecycle, request), + runtime.apiConfig()); + } + + private ThingifierApiOperationResult resultFor( + final ApiResponse response, + final ThingifierApiLifecycleContext lifecycle, + final String operationType) { + final ThingCommandResult writeResult = + lifecycle == null ? null : lifecycle.writeCommandResult(); + return new ThingifierApiOperationResult( + response.getStatusCode(), operationType, response, writeResult); + } + + private HttpHeadersBlock requestHeaders( + final ThingifierRequestContext requestContext, + final ThingifierApiLifecycleContext lifecycle, + final ApiRequestEnvelope request) { + if (request != null) { + return request.headers(); + } + if (lifecycle != null) { + return lifecycle.headers(); + } + return requestContext == null ? new HttpHeadersBlock() : requestContext.headers(); + } + + private QueryFilterParams queryParams( + final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request) { + if (request != null) { + return request.queryParams(); + } + if (lifecycle != null) { + return lifecycle.queryParams(); + } + return new QueryFilterParams(); + } + + private ApiBodyFields bodyFields( + final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request) { + if (request != null) { + return request.bodyFields(); + } + if (lifecycle != null) { + return lifecycle.bodyFields(); + } + return ApiBodyFields.empty(); + } + + private String rawBody( + final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request) { + if (request != null) { + return request.body(); + } + if (lifecycle != null) { + return lifecycle.rawBody(); + } + return ""; + } + + private String targetEntityName( + final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null && lifecycle.targetEntity() != null) { + return lifecycle.targetEntity().getName(); + } + if (route instanceof CollectionRoute) { + return ((CollectionRoute) route).entity().name(); + } + if (route instanceof InstanceRoute) { + return ((InstanceRoute) route).entity().name(); + } + if (route instanceof RelationshipCollectionRoute) { + return relationshipTargetEntityName((RelationshipCollectionRoute) route); + } + if (route instanceof RelationshipInstanceRoute) { + return relationshipTargetEntityName((RelationshipInstanceRoute) route); + } + return null; + } + + private String targetIdentifier( + final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null) { + return lifecycle.targetIdentifier(); + } + if (route instanceof InstanceRoute) { + return ((InstanceRoute) route).identifier(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).childIdentifier(); + } + return null; + } + + private String parentEntityName( + final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null && lifecycle.parentEntity() != null) { + return lifecycle.parentEntity().getName(); + } + if (route instanceof RelationshipCollectionRoute) { + return ((RelationshipCollectionRoute) route).parentEntity().name(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).parentEntity().name(); + } + return null; + } + + private String parentIdentifier( + final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null) { + return lifecycle.parentIdentifier(); + } + if (route instanceof RelationshipCollectionRoute) { + return ((RelationshipCollectionRoute) route).parentIdentifier(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).parentIdentifier(); + } + return null; + } + + private String relationshipName( + final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null) { + return lifecycle.relationshipName(); + } + if (route instanceof RelationshipCollectionRoute) { + return ((RelationshipCollectionRoute) route).relationshipName(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).relationshipName(); + } + return null; + } + + private String childIdentifier( + final ThingRoute route, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null) { + return lifecycle.childIdentifier(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).childIdentifier(); + } + return null; + } + + private String relationshipTargetEntityName(final RelationshipCollectionRoute route) { + return relationshipTargetEntityName(route.parentEntity().name(), route.relationshipName()); + } + + private String relationshipTargetEntityName(final RelationshipInstanceRoute route) { + return relationshipTargetEntityName(route.parentEntity().name(), route.relationshipName()); + } + + private String relationshipTargetEntityName( + final String parentEntityName, final String relationshipName) { + final ThingRoute parentRoute = runtime.routeFor(RoutingVerb.GET, parentEntityName); + if (!(parentRoute instanceof CollectionRoute)) { + return null; + } + for (RelationshipSpec relationship : + ((CollectionRoute) parentRoute).entity().relationships()) { + if (relationship.name().equals(relationshipName)) { + return relationship.toEntityName(); + } + } + return null; + } + + private String operationTypeFor( + final RoutingVerb verb, final ThingifierApiLifecycleContext lifecycle) { + if (lifecycle != null && lifecycle.writeCommand() != null) { + return operationTypeFor(lifecycle.writeCommand()); + } + if (verb == RoutingVerb.QUERY) { + return "QUERY"; + } + if (verb == RoutingVerb.GET || verb == RoutingVerb.HEAD) { + return "READ"; + } + if (verb == RoutingVerb.DELETE) { + return "DELETE"; + } + if (verb == RoutingVerb.PATCH) { + return "PATCH"; + } + if (verb == RoutingVerb.PUT) { + return "REPLACE"; + } + if (verb == RoutingVerb.POST) { + return "WRITE"; + } + return ""; + } + + private String operationTypeFor(final ThingWriteCommand command) { + final String commandName = command.getClass().getSimpleName(); + switch (commandName) { + case "CreateThingCommand": + return "CREATE"; + case "AmendThingCommand": + return "UPDATE"; + case "ReplaceThingCommand": + return "REPLACE"; + case "PatchThingDocumentCommand": + return "PATCH"; + case "DeleteThingCommand": + return "DELETE"; + case "CreateAndConnectRelationshipCommand": + return "CREATE_AND_CONNECT"; + case "ConnectExistingRelationshipCommand": + return "CONNECT"; + case "UpdateConnectedRelationshipCommand": + return "UPDATE_CONNECTED"; + case "DisconnectRelationshipCommand": + return "DISCONNECT"; + case "RelateThingCommand": + return "RELATE"; + default: + return commandName; + } + } + + private void logCallbackFailure( + final ThingifierApiOperationCallbackDefinition definition, + final RoutingVerb verb, + final String publicPath, + final ApiResponse response, + final Exception exception) { + LOGGER.log( + Level.SEVERE, + String.format( + "Route operation callback '%s' failed for %s %s after status %d", + definition.name(), verb, publicPath, response.getStatusCode()), + exception); + } + + private String callbackFailureMessage( + final ThingifierApiOperationCallbackDefinition definition, + final RoutingVerb verb, + final String publicPath) { + return String.format( + "Route operation callback '%s' failed for %s %s", + definition.name(), verb, publicPath); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java index 6609f5a9..470eed56 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java @@ -6,6 +6,7 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.FixedRouteResourcePreparer; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteApiResponsePolicyApplier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteAuthPolicy; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteOperationCallbackApplier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleContext; @@ -135,6 +136,7 @@ public ApiResponse get( request.path(), context, lifecycle, + request, () -> get.handle(request.path(), request.queryParams(), context, lifecycle)); } @@ -187,6 +189,7 @@ public ApiResponse head( request.path(), context, lifecycle, + request, () -> { final ApiResponse response = get.handle(request.path(), request.queryParams(), context, lifecycle); @@ -220,6 +223,7 @@ public ApiResponse query( request.path(), context, lifecycle, + request, () -> query.handle( request.path(), @@ -268,6 +272,7 @@ public ApiResponse delete( request.path(), context, lifecycle, + request, () -> delete.handle(request.path(), request.queryParams(), context, lifecycle)); } @@ -321,6 +326,7 @@ public ApiResponse post( request.path(), context, lifecycle, + request, () -> post.handle( request.path(), @@ -397,6 +403,7 @@ public ApiResponse put( request.path(), context, lifecycle, + request, () -> put.handle( request.path(), @@ -484,6 +491,7 @@ public ApiResponse patch( request.path(), context, lifecycle, + request, () -> patch.handle( request.path(), @@ -558,19 +566,44 @@ private ApiResponse withAuthorizedResponsePolicy( final ThingifierRequestContext context, final ThingifierApiLifecycleContext lifecycle, final Supplier action) { + return withAuthorizedResponsePolicy(verb, url, context, lifecycle, null, action); + } + + /** + * Applies auth, fixed-resource preparation, response policy, and route operation callbacks. + * + *

Callbacks run after response policies so they see the route-shaped API result, and before + * legacy HTTP response hooks so application code still has one final compatibility hook phase. + * + * @param verb routing verb used for route-rule lookup + * @param url generated API path + * @param context request context containing the active store + * @param lifecycle lifecycle context when called through HTTP processing, otherwise null + * @param request parsed request envelope, or null for older direct-call helpers + * @param action handler action to run when auth allows the request + * @return response after auth, response policy, and route callbacks have been applied + */ + private ApiResponse withAuthorizedResponsePolicy( + final RoutingVerb verb, + final String url, + final ThingifierRequestContext context, + final ThingifierApiLifecycleContext lifecycle, + final ApiRequestEnvelope request, + final Supplier action) { final ApiResponse authResponse = lifecycle == null ? authPolicy.rejectIfNotAuthorized(verb, url, context) : null; if (authResponse != null) { - return withResponsePolicy(verb, url, authResponse, context); + return withResponsePolicy(verb, url, authResponse, context, lifecycle, request); } final ThingRoute route = lifecycle == null ? runtime.routeFor(verb, url) : lifecycle.route(); final ApiResponse fixedResourceResponse = new FixedRouteResourcePreparer(runtime).prepare(verb, url, route, context); if (fixedResourceResponse != null) { - return withResponsePolicy(verb, url, fixedResourceResponse, context); + return withResponsePolicy( + verb, url, fixedResourceResponse, context, lifecycle, request); } - return withResponsePolicy(verb, url, action.get(), context); + return withResponsePolicy(verb, url, action.get(), context, lifecycle, request); } /** @@ -604,15 +637,20 @@ private ApiResponse withResponsePolicy( final RoutingVerb verb, final String url, final ApiResponse response, - final ThingifierRequestContext context) { + final ThingifierRequestContext context, + final ThingifierApiLifecycleContext lifecycle, + final ApiRequestEnvelope request) { final ApiResponse responseWithRepository = withRepository(response, context); - return new RouteApiResponsePolicyApplier(runtime) - .apply( - verb, - url, - responseWithRepository, - context.headers(), - apiResponse -> applyResponseEntityView(verb, url, apiResponse)); + final ApiResponse policyResponse = + new RouteApiResponsePolicyApplier(runtime) + .apply( + verb, + url, + responseWithRepository, + context.headers(), + apiResponse -> applyResponseEntityView(verb, url, apiResponse)); + return new RouteOperationCallbackApplier(runtime) + .apply(verb, url, policyResponse, context, lifecycle, request); } /** diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java new file mode 100644 index 00000000..a109f49c --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/CallbackFailurePolicy.java @@ -0,0 +1,26 @@ +package uk.co.compendiumdev.thingifier.api.callbacks; + +/** + * Controls how Thingifier reacts when a route operation callback throws. + * + *

Callbacks are trusted application code running after Thingifier has decided an operation + * result. Applications can choose whether a side-effect failure should fail the visible API request + * or be logged while preserving the original response. + */ +public enum CallbackFailurePolicy { + /** + * Convert the callback exception into a 500 API response. + * + *

This is the default because silently skipping application side effects can leave + * application-owned state inconsistent with Thingifier-managed data. + */ + FAIL_REQUEST, + + /** + * Log the callback exception and preserve the original operation response. + * + *

Use this when the callback is observational, such as diagnostics or best-effort metrics, + * and the API operation should not fail because the callback failed. + */ + LOG_AND_CONTINUE +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java new file mode 100644 index 00000000..61f31e31 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallback.java @@ -0,0 +1,21 @@ +package uk.co.compendiumdev.thingifier.api.callbacks; + +/** + * Trusted application callback invoked after a route operation has produced an API result. + * + *

Use route operation callbacks for application side effects such as audit logging, projections, + * cache invalidation, or synchronising app-owned state. Response shaping should stay in route + * response policies or response hooks so callbacks can remain focused on observing the completed + * operation. + */ +@FunctionalInterface +public interface ThingifierApiOperationCallback { + + /** + * Runs the application callback for one completed route operation. + * + * @param context immutable route, request, auth, and data-scope information + * @param result immutable operation outcome details + */ + void run(ThingifierApiOperationContext context, ThingifierApiOperationResult result); +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java new file mode 100644 index 00000000..448ea488 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationCallbackDefinition.java @@ -0,0 +1,156 @@ +package uk.co.compendiumdev.thingifier.api.callbacks; + +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; + +/** + * Runtime-only registration for one route operation callback. + * + *

The definition is code-only by design. Java callbacks cannot safely round-trip through YAML or + * OpenAPI, so Thingifier stores them only in the in-memory API contract and uses the name for + * diagnostics. + */ +public final class ThingifierApiOperationCallbackDefinition { + + /** Outcome selector used when deciding whether a callback should run. */ + public enum Outcome { + /** Run for any completed outcome. */ + ANY, + + /** Run only for 2xx/3xx operation responses. */ + SUCCESS, + + /** Run only for non-success operation responses. */ + FAILURE, + + /** Run only when the final status code matches {@link #statusCode()}. */ + STATUS + } + + private final ThingifierApiRouteRule routeRule; + private final String name; + private final Outcome outcome; + private final Integer statusCode; + private final ThingifierApiOperationCallback callback; + private CallbackFailurePolicy failurePolicy; + + /** + * Creates a callback registration. + * + * @param routeRule route that owns the callback + * @param name stable diagnostic name + * @param outcome outcome selector + * @param statusCode status code for {@link Outcome#STATUS}, otherwise null + * @param callback trusted application callback + */ + public ThingifierApiOperationCallbackDefinition( + final ThingifierApiRouteRule routeRule, + final String name, + final Outcome outcome, + final Integer statusCode, + final ThingifierApiOperationCallback callback) { + if (routeRule == null) { + throw new IllegalArgumentException("route rule is required"); + } + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("callback name is required"); + } + if (outcome == null) { + throw new IllegalArgumentException("callback outcome is required"); + } + if (outcome == Outcome.STATUS && statusCode == null) { + throw new IllegalArgumentException("status callback requires a status code"); + } + if (callback == null) { + throw new IllegalArgumentException("callback is required"); + } + this.routeRule = routeRule; + this.name = name.trim(); + this.outcome = outcome; + this.statusCode = statusCode; + this.callback = callback; + this.failurePolicy = CallbackFailurePolicy.FAIL_REQUEST; + } + + /** + * Sets the failure policy for this callback. + * + * @param policy callback exception handling policy + * @return owning route rule so route configuration can continue fluently + */ + public ThingifierApiRouteRule onCallbackFailure(final CallbackFailurePolicy policy) { + if (policy == null) { + throw new IllegalArgumentException("callback failure policy is required"); + } + this.failurePolicy = policy; + return routeRule; + } + + /** + * Returns the stable diagnostic callback name. + * + * @return callback name + */ + public String name() { + return name; + } + + /** + * Returns the outcome selector for this callback. + * + * @return configured outcome selector + */ + public Outcome outcome() { + return outcome; + } + + /** + * Returns the status code matched by status-specific callbacks. + * + * @return status code, or null for non-status callbacks + */ + public Integer statusCode() { + return statusCode; + } + + /** + * Returns the application callback. + * + * @return trusted callback + */ + public ThingifierApiOperationCallback callback() { + return callback; + } + + /** + * Returns the configured callback failure policy. + * + * @return failure policy + */ + public CallbackFailurePolicy failurePolicy() { + return failurePolicy; + } + + /** + * Reports whether this callback should run for the supplied result. + * + * @param result operation result + * @return true when the outcome selector matches + */ + public boolean matches(final ThingifierApiOperationResult result) { + if (result == null) { + return false; + } + switch (outcome) { + case ANY: + return true; + case SUCCESS: + return result.successful(); + case FAILURE: + return result.failed(); + case STATUS: + return statusCode != null && statusCode == result.statusCode(); + default: + return false; + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java new file mode 100644 index 00000000..289c515a --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java @@ -0,0 +1,286 @@ +package uk.co.compendiumdev.thingifier.api.callbacks; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; + +/** + * Immutable route and request context supplied to route operation callbacks. + * + *

The context reflects trusted routing, authentication, and data-scope decisions that have + * already happened before the operation callback runs. Applications can use it to update app-owned + * state without reparsing public paths or re-discovering the active store. + */ +public final class ThingifierApiOperationContext { + + private final RoutingVerb verb; + private final String publicPath; + private final ThingRoute route; + private final ThingifierApiRouteRule routeRule; + private final String targetEntityName; + private final String targetIdentifier; + private final String parentEntityName; + private final String parentIdentifier; + private final String relationshipName; + private final String childIdentifier; + private final String dataScopeName; + private final ThingStore store; + private final Map authenticatedPrincipals; + private final HttpHeadersBlock requestHeaders; + private final QueryFilterParams queryParams; + private final ApiBodyFields parsedRequestBody; + private final String rawRequestBody; + private final ThingifierApiConfig apiConfig; + + /** + * Creates a callback context. + * + * @param verb route verb being processed + * @param publicPath public request path + * @param route resolved generated route + * @param routeRule matched route rule that owns the callback + * @param targetEntityName target entity name, or null + * @param targetIdentifier target identifier, or null + * @param parentEntityName relationship parent entity name, or null + * @param parentIdentifier relationship parent identifier, or null + * @param relationshipName relationship route name, or null + * @param childIdentifier relationship child identifier, or null + * @param dataScopeName active data-scope name + * @param store active store + * @param authenticatedPrincipals authenticated principals by scheme name + * @param requestHeaders request headers + * @param queryParams parsed query parameters + * @param parsedRequestBody parsed body fields + * @param rawRequestBody raw request body text + * @param apiConfig active API configuration + */ + public ThingifierApiOperationContext( + final RoutingVerb verb, + final String publicPath, + final ThingRoute route, + final ThingifierApiRouteRule routeRule, + final String targetEntityName, + final String targetIdentifier, + final String parentEntityName, + final String parentIdentifier, + final String relationshipName, + final String childIdentifier, + final String dataScopeName, + final ThingStore store, + final Map authenticatedPrincipals, + final HttpHeadersBlock requestHeaders, + final QueryFilterParams queryParams, + final ApiBodyFields parsedRequestBody, + final String rawRequestBody, + final ThingifierApiConfig apiConfig) { + this.verb = verb; + this.publicPath = normalizedPublicPath(publicPath); + this.route = route; + this.routeRule = routeRule; + this.targetEntityName = targetEntityName; + this.targetIdentifier = targetIdentifier; + this.parentEntityName = parentEntityName; + this.parentIdentifier = parentIdentifier; + this.relationshipName = relationshipName; + this.childIdentifier = childIdentifier; + this.dataScopeName = dataScopeName; + this.store = store; + this.authenticatedPrincipals = + Map.copyOf( + authenticatedPrincipals == null + ? Map.of() + : new HashMap<>(authenticatedPrincipals)); + this.requestHeaders = copyHeaders(requestHeaders); + this.queryParams = copyQueryParams(queryParams); + this.parsedRequestBody = + parsedRequestBody == null ? ApiBodyFields.empty() : parsedRequestBody; + this.rawRequestBody = rawRequestBody == null ? "" : rawRequestBody; + this.apiConfig = apiConfig; + } + + /** + * @return route verb being processed + */ + public RoutingVerb verb() { + return verb; + } + + /** + * @return public path requested by the caller + */ + public String publicPath() { + return publicPath; + } + + /** + * @return resolved generated route target + */ + public ThingRoute route() { + return route; + } + + /** + * @return matched route rule that registered the callback + */ + public ThingifierApiRouteRule routeRule() { + return routeRule; + } + + /** + * @return route pattern declared on the matched route rule + */ + public String matchedRoutePattern() { + return routeRule == null ? publicPath : routeRule.pathPattern(); + } + + /** + * @return targeted entity name when the route is entity-backed + */ + public Optional targetEntityName() { + return Optional.ofNullable(targetEntityName); + } + + /** + * @return targeted identifier for instance and fixed routes + */ + public Optional targetIdentifier() { + return Optional.ofNullable(targetIdentifier); + } + + /** + * @return relationship parent entity name when applicable + */ + public Optional parentEntityName() { + return Optional.ofNullable(parentEntityName); + } + + /** + * @return relationship parent identifier when applicable + */ + public Optional parentIdentifier() { + return Optional.ofNullable(parentIdentifier); + } + + /** + * @return relationship route name when applicable + */ + public Optional relationshipName() { + return Optional.ofNullable(relationshipName); + } + + /** + * @return relationship child identifier when applicable + */ + public Optional childIdentifier() { + return Optional.ofNullable(childIdentifier); + } + + /** + * @return active data-scope name after authentication had a chance to select it + */ + public String dataScopeName() { + return dataScopeName; + } + + /** + * @return active Thingifier store for the operation + */ + public ThingStore store() { + return store; + } + + /** + * Returns the principal for the common single-scheme case. + * + * @return first authenticated principal, or null when none was stored + */ + public Object authenticatedPrincipal() { + if (authenticatedPrincipals.isEmpty()) { + return null; + } + return authenticatedPrincipals.values().iterator().next(); + } + + /** + * Returns the principal for a named security scheme. + * + * @param schemeName security scheme name + * @return authenticated principal, or null + */ + public Object authenticatedPrincipal(final String schemeName) { + return authenticatedPrincipals.get(schemeName); + } + + /** + * @return authenticated principals keyed by scheme name + */ + public Map authenticatedPrincipals() { + return authenticatedPrincipals; + } + + /** + * @return copy of request headers + */ + public HttpHeadersBlock requestHeaders() { + return copyHeaders(requestHeaders); + } + + /** + * @return copy of parsed query parameters + */ + public QueryFilterParams queryParams() { + return copyQueryParams(queryParams); + } + + /** + * @return parsed request body fields, or empty fields when no body was parsed + */ + public ApiBodyFields parsedRequestBody() { + return parsedRequestBody; + } + + /** + * @return raw request body text, or an empty string when no body was supplied + */ + public String rawRequestBody() { + return rawRequestBody; + } + + /** + * @return active API configuration + */ + public ThingifierApiConfig apiConfig() { + return apiConfig; + } + + private HttpHeadersBlock copyHeaders(final HttpHeadersBlock source) { + HttpHeadersBlock copy = new HttpHeadersBlock(); + if (source != null) { + copy.putAll(source); + } + return copy; + } + + private QueryFilterParams copyQueryParams(final QueryFilterParams source) { + QueryFilterParams copy = new QueryFilterParams(); + if (source != null) { + copy.addAll(source); + } + return copy; + } + + private String normalizedPublicPath(final String path) { + if (path == null || path.isEmpty()) { + return ""; + } + return path.startsWith("/") ? path : "/" + path; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java new file mode 100644 index 00000000..0d7f8a72 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationResult.java @@ -0,0 +1,158 @@ +package uk.co.compendiumdev.thingifier.api.callbacks; + +import java.util.List; +import java.util.Optional; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.ThingCommandResult; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +/** + * Immutable operation outcome supplied to route operation callbacks. + * + *

The result exposes stable API-level facts first, such as status and returned entity data, and + * uses command/query details only to recover useful affected-instance information for write + * operations whose response body may have been suppressed by route policy. + */ +public final class ThingifierApiOperationResult { + + private final int statusCode; + private final String operationType; + private final ApiResponse apiResponse; + private final ThingCommandResult writeCommandResult; + + /** + * Creates an operation result. + * + * @param statusCode final API status code + * @param operationType operation label such as READ, CREATE, UPDATE, DELETE, or QUERY + * @param apiResponse structured API response + * @param writeCommandResult write command result when available + */ + public ThingifierApiOperationResult( + final int statusCode, + final String operationType, + final ApiResponse apiResponse, + final ThingCommandResult writeCommandResult) { + this.statusCode = statusCode; + this.operationType = operationType == null ? "" : operationType; + this.apiResponse = apiResponse; + this.writeCommandResult = writeCommandResult; + } + + /** + * @return final API status code visible to the caller + */ + public int statusCode() { + return statusCode; + } + + /** + * @return true for 2xx and 3xx responses + */ + public boolean successful() { + return statusCode >= 200 && statusCode < 400; + } + + /** + * @return true for responses outside the success range + */ + public boolean failed() { + return !successful(); + } + + /** + * @return operation label resolved by Thingifier where possible + */ + public String operationType() { + return operationType; + } + + /** + * @return true when the operation created a new instance + */ + public boolean created() { + return (writeCommandResult != null && writeCommandResult.createdInstance()) + || statusCode == 201; + } + + /** + * @return true when the operation appears to update an existing instance + */ + public boolean updated() { + return successful() + && !created() + && (operationType.equals("UPDATE") + || operationType.equals("REPLACE") + || operationType.equals("PATCH") + || operationType.equals("UPDATE_CONNECTED")); + } + + /** + * @return true when the operation appears to delete or disconnect a resource + */ + public boolean deleted() { + return successful() + && (operationType.equals("DELETE") || operationType.equals("DISCONNECT")); + } + + /** + * Reports whether a single affected or returned instance is available. + * + * @return true when {@link #singleInstance()} can be called safely + */ + public boolean hasSingleInstance() { + return maybeSingleInstance().isPresent(); + } + + /** + * Returns the single affected or returned instance. + * + * @return single instance + * @throws IllegalStateException when no single instance is available + */ + public EntityInstance singleInstance() { + return maybeSingleInstance() + .orElseThrow(() -> new IllegalStateException("operation has no single instance")); + } + + /** + * Returns the single affected or returned instance when available. + * + * @return optional single instance + */ + public Optional maybeSingleInstance() { + if (apiResponse != null && apiResponse.hasReturnedInstance()) { + return Optional.of(apiResponse.getReturnedInstance()); + } + if (writeCommandResult != null && writeCommandResult.getInstance() != null) { + return Optional.of(writeCommandResult.getInstance()); + } + return Optional.empty(); + } + + /** + * @return true when the response contains a collection of instances + */ + public boolean hasInstanceCollection() { + return apiResponse != null && apiResponse.isCollection(); + } + + /** + * Returns the instance collection from the response. + * + * @return immutable copy of returned instances, or an empty list + */ + public List instanceCollection() { + if (!hasInstanceCollection()) { + return List.of(); + } + return List.copyOf(apiResponse.getReturnedInstanceCollection()); + } + + /** + * @return structured API response after route response policy has been applied + */ + public ApiResponse apiResponse() { + return apiResponse; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java index 50917a12..017ffd82 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java @@ -7,6 +7,9 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallback; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallbackDefinition; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationCallbackDefinition.Outcome; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; @@ -45,6 +48,7 @@ public final class ThingifierApiRouteRule { private final List authEnforcementSchemeNames; private final List authorizers; private final List apiOperationValidators; + private final List operationCallbacks; private RouteApiResponsePolicy successResponsePolicy; private final Map errorResponsePolicies; private final Map> conditionalErrorResponsePolicies; @@ -80,6 +84,7 @@ public final class ThingifierApiRouteRule { this.authEnforcementSchemeNames = new java.util.ArrayList<>(); this.authorizers = new java.util.ArrayList<>(); this.apiOperationValidators = new java.util.ArrayList<>(); + this.operationCallbacks = new java.util.ArrayList<>(); this.successResponsePolicy = null; this.errorResponsePolicies = new HashMap<>(); this.conditionalErrorResponsePolicies = new HashMap<>(); @@ -729,6 +734,136 @@ public List apiOperationValidators() { return Collections.unmodifiableList(apiOperationValidators); } + /** + * Registers a callback that runs after any completed outcome for this route. + * + *

Operation callbacks are trusted, code-only application side effects. They run after + * Thingifier has created and route-shaped an {@link + * uk.co.compendiumdev.thingifier.api.response.ApiResponse}, and before legacy response hooks + * render or override the final HTTP response. + * + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterOperation( + final ThingifierApiOperationCallback callback) { + return afterOperation(defaultCallbackName("after-operation"), callback); + } + + /** + * Registers a named callback that runs after any completed outcome for this route. + * + * @param name stable callback name used in diagnostics + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterOperation( + final String name, final ThingifierApiOperationCallback callback) { + return addOperationCallback(name, Outcome.ANY, null, callback); + } + + /** + * Registers a callback that runs only for successful route outcomes. + * + *

A successful outcome is based on the final route-shaped status code in the 2xx or 3xx + * range. + * + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterSuccessfulOperation( + final ThingifierApiOperationCallback callback) { + return afterSuccessfulOperation( + defaultCallbackName("after-successful-operation"), callback); + } + + /** + * Registers a named callback that runs only for successful route outcomes. + * + * @param name stable callback name used in diagnostics + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterSuccessfulOperation( + final String name, final ThingifierApiOperationCallback callback) { + return addOperationCallback(name, Outcome.SUCCESS, null, callback); + } + + /** + * Registers a callback that runs only for failed route outcomes. + * + *

Use this for route-specific failure observation. It is not a replacement for response + * policies; callbacks should perform application side effects rather than shape response + * bodies. + * + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterFailedOperation( + final ThingifierApiOperationCallback callback) { + return afterFailedOperation(defaultCallbackName("after-failed-operation"), callback); + } + + /** + * Registers a named callback that runs only for failed route outcomes. + * + * @param name stable callback name used in diagnostics + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterFailedOperation( + final String name, final ThingifierApiOperationCallback callback) { + return addOperationCallback(name, Outcome.FAILURE, null, callback); + } + + /** + * Registers a callback that runs only when the final status code matches. + * + * @param statusCode final API status code to match + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterStatus( + final int statusCode, final ThingifierApiOperationCallback callback) { + return afterStatus(defaultCallbackName("after-status-" + statusCode), statusCode, callback); + } + + /** + * Registers a named callback that runs only when the final status code matches. + * + * @param name stable callback name used in diagnostics + * @param statusCode final API status code to match + * @param callback callback to run + * @return callback registration for optional failure-policy configuration + */ + public ThingifierApiOperationCallbackDefinition afterStatus( + final String name, + final int statusCode, + final ThingifierApiOperationCallback callback) { + return addOperationCallback(name, Outcome.STATUS, statusCode, callback); + } + + /** + * Reports whether this route has operation callbacks. + * + * @return true when callbacks are registered + */ + public boolean hasOperationCallbacks() { + return !operationCallbacks.isEmpty(); + } + + /** + * Returns operation callbacks in declaration order. + * + *

Callbacks are runtime-only and intentionally absent from YAML export/import and public + * OpenAPI because Java functions cannot safely round-trip through those formats. + * + * @return immutable callback registrations + */ + public List operationCallbacks() { + return Collections.unmodifiableList(operationCallbacks); + } + /** * Configures response shaping for non-error responses returned by this route. * @@ -1191,6 +1326,22 @@ private List normalizedSchemeNames(final String... schemeNames) { return normalizedSchemeNames; } + private ThingifierApiOperationCallbackDefinition addOperationCallback( + final String name, + final Outcome outcome, + final Integer statusCode, + final ThingifierApiOperationCallback callback) { + final ThingifierApiOperationCallbackDefinition definition = + new ThingifierApiOperationCallbackDefinition( + this, name, outcome, statusCode, callback); + operationCallbacks.add(definition); + return definition; + } + + private String defaultCallbackName(final String prefix) { + return prefix + "-" + (operationCallbacks.size() + 1); + } + private String requireText(final String value, final String label) { if (value == null || value.trim().isEmpty()) { throw new IllegalArgumentException(label + " is required"); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java new file mode 100644 index 00000000..91c02494 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/callbacks/RouteOperationCallbackTest.java @@ -0,0 +1,323 @@ +package uk.co.compendiumdev.thingifier.api.callbacks; + +import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.UPDATE; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.AUTO_INCREMENT; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.http.messagehooks.HttpApiResponseHook; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticationResult; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +class RouteOperationCallbackTest { + + @Test + void afterSuccessfulOperationReceivesFixedRouteUpdateContextAndInstance() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "old"); + final AtomicReference seenContext = new AtomicReference<>(); + final AtomicReference seenResult = new AtomicReference<>(); + postSecretNoteRoute(thingifier) + .afterSuccessfulOperation( + "sync-note", + (context, result) -> { + seenContext.set(context); + seenResult.set(result); + }); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .post( + jsonPost("/secret/note", "{\"text\":\"new\"}") + .addHeader("X-Trace", "trace-1")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("/secret/note", seenContext.get().publicPath()); + Assertions.assertEquals("/secret/note", seenContext.get().matchedRoutePattern()); + Assertions.assertEquals("secretnote", seenContext.get().targetEntityName().orElseThrow()); + Assertions.assertEquals("note", seenContext.get().targetIdentifier().orElseThrow()); + Assertions.assertEquals( + EntityRelModel.DEFAULT_DATABASE_NAME, seenContext.get().dataScopeName()); + Assertions.assertEquals("trace-1", seenContext.get().requestHeaders().get("X-Trace")); + Assertions.assertEquals("{\"text\":\"new\"}", seenContext.get().rawRequestBody()); + Assertions.assertEquals(200, seenResult.get().statusCode()); + Assertions.assertTrue(seenResult.get().successful()); + Assertions.assertTrue(seenResult.get().updated()); + Assertions.assertEquals( + "new", seenResult.get().singleInstance().getFieldValue("text").asString()); + } + + @Test + void callbackReceivesAuthSelectedDataScopeAndPrincipal() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, "tenant-one", "note", "tenant-old"); + thingifier + .apiSpec() + .authenticator( + "tenantToken", + context -> + ThingifierApiAuthenticationResult.authenticated("tenant-principal") + .useDataScope( + "tenant-one", + DataScopeCreationPolicy.USE_EXISTING_ONLY)); + final AtomicReference seenContext = new AtomicReference<>(); + postSecretNoteRoute(thingifier) + .secureWithBearerAuth("tenantToken") + .afterSuccessfulOperation( + "capture-tenant", (context, result) -> seenContext.set(context)); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .post( + jsonPost("/secret/note", "{\"text\":\"tenant-new\"}") + .addHeader("Authorization", "Bearer valid-token")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("tenant-one", seenContext.get().dataScopeName()); + Assertions.assertSame(thingifier.getStore("tenant-one"), seenContext.get().store()); + Assertions.assertEquals("tenant-principal", seenContext.get().authenticatedPrincipal()); + Assertions.assertEquals( + "tenant-new", + secretNote(thingifier, "tenant-one", "note").getFieldValue("text").asString()); + } + + @Test + void afterSuccessfulOperationDoesNotRunForValidationFailure() { + final Thingifier thingifier = todoModel(); + final AtomicInteger callbackCount = new AtomicInteger(); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .afterSuccessfulOperation((context, result) -> callbackCount.incrementAndGet()); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).post(jsonPost("/todos", "{}")); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertEquals(0, callbackCount.get()); + } + + @Test + void afterFailedOperationRunsForValidationFailure() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenResult = new AtomicReference<>(); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .afterFailedOperation( + "capture-failure", (context, result) -> seenResult.set(result)); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).post(jsonPost("/todos", "{}")); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertEquals(422, seenResult.get().statusCode()); + Assertions.assertTrue(seenResult.get().failed()); + } + + @Test + void statusCallbackRunsOnlyForMatchingFinalStatus() { + final Thingifier thingifier = secretModel(); + final AtomicInteger callbackCount = new AtomicInteger(); + getSecretNoteRoute(thingifier) + .afterStatus(404, (context, result) -> callbackCount.incrementAndGet()); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertEquals(1, callbackCount.get()); + } + + @Test + void multipleCallbacksRunInDeclarationOrder() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible"); + final List calls = new ArrayList<>(); + final ThingifierApiRouteRule route = getSecretNoteRoute(thingifier); + route.afterOperation("first", (context, result) -> calls.add("first")); + route.afterStatus(200, (context, result) -> calls.add("second")); + route.afterSuccessfulOperation("third", (context, result) -> calls.add("third")); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(List.of("first", "second", "third"), calls); + } + + @Test + void callbackRunsBeforeLegacyResponseHook() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible"); + final List calls = new ArrayList<>(); + getSecretNoteRoute(thingifier) + .afterSuccessfulOperation("callback", (context, result) -> calls.add("callback")); + final HttpApiResponseHook responseHook = + (request, response, config) -> { + calls.add("response hook"); + return null; + }; + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier, null, List.of(responseHook)) + .get(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(List.of("callback", "response hook"), calls); + } + + @Test + void throwingCallbackFailsRequestByDefault() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible"); + getSecretNoteRoute(thingifier) + .afterSuccessfulOperation( + "explode", + (context, result) -> { + throw new IllegalStateException("boom"); + }); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(500, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains("explode")); + } + + @Test + void logAndContinueCallbackFailurePreservesOriginalResponse() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "visible"); + getSecretNoteRoute(thingifier) + .afterSuccessfulOperation( + "best-effort", + (context, result) -> { + throw new IllegalStateException("boom"); + }) + .onCallbackFailure(CallbackFailurePolicy.LOG_AND_CONTINUE); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains("visible")); + } + + @Test + void directApiInvokesRouteCallbacks() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "note", "old"); + final AtomicReference seenResult = new AtomicReference<>(); + postSecretNoteRoute(thingifier) + .afterSuccessfulOperation("direct", (context, result) -> seenResult.set(result)); + + final ApiResponse response = + thingifier + .api() + .post( + "secret/note", + parser(thingifier, "{\"text\":\"new\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "new", seenResult.get().singleInstance().getFieldValue("text").asString()); + } + + private ThingifierApiRouteRule getSecretNoteRoute(final Thingifier thingifier) { + return thingifier + .apiSpec() + .route(RoutingVerb.GET, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note"); + } + + private ThingifierApiRouteRule postSecretNoteRoute(final Thingifier thingifier) { + return thingifier + .apiSpec() + .route(RoutingVerb.POST, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note") + .entityCan(UPDATE); + } + + private Thingifier secretModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition note = thingifier.defineThing("secretnote", "secretnotes", 10); + note.addAsPrimaryKeyField(Field.is("id", STRING)); + note.addField(Field.is("text", STRING)); + return thingifier; + } + + private Thingifier todoModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition todo = thingifier.defineThing("todo", "todos", 10); + todo.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT)); + todo.addField(Field.is("title", STRING).makeMandatory()); + return thingifier; + } + + private EntityInstance createSecretNote( + final Thingifier thingifier, + final String dataScopeName, + final String id, + final String text) { + thingifier.getERmodel().createInstanceDatabaseIfNotExisting(dataScopeName); + final EntityDefinition note = thingifier.getDefinitionNamed("secretnote"); + return thingifier + .getStore(dataScopeName) + .entities() + .create( + EntityInstanceDraft.forEntity(note) + .withField("id", id) + .withField("text", text)); + } + + private EntityInstance secretNote( + final Thingifier thingifier, final String dataScopeName, final String id) { + return thingifier + .getStore(dataScopeName) + .entityQueries() + .findByPrimaryKey(thingifier.getDefinitionNamed("secretnote"), id); + } + + private HttpApiRequest jsonRequest(final String path) { + return new HttpApiRequest(path).addHeader("Accept", "application/json"); + } + + private HttpApiRequest jsonPost(final String path, final String body) { + return new HttpApiRequest(path) + .setVerb("POST") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .setBody(body); + } + + private BodyParser parser(final Thingifier thingifier, final String body) { + return new BodyParser( + new HttpApiRequest("/request") + .addHeader("Content-Type", "application/json") + .setBody(body), + thingifier.getThingNames()); + } +}