From 0c1b3f579c26004d7a50cd29691e9087fb7de1e0 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 23 Aug 2026 18:37:07 +0100 Subject: [PATCH] Add conditional route error response policies Closes #164 --- .../RouteApiResponsePolicyApplier.java | 68 ++++- .../api/ThingifierRestAPIHandler.java | 1 + .../api/http/ThingifierHttpApi.java | 1 + .../api/http/headers/HttpHeadersBlock.java | 12 + .../thingifier/api/response/ApiResponse.java | 15 ++ .../api/response/RouteApiResponsePolicy.java | 160 ++++++++++++ .../api/spec/ThingifierApiRouteRule.java | 54 ++++ .../response/RouteApiResponsePolicyTest.java | 234 ++++++++++++++++++ 8 files changed, 535 insertions(+), 10 deletions(-) diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteApiResponsePolicyApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteApiResponsePolicyApplier.java index a43777e0..60d0da51 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteApiResponsePolicyApplier.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteApiResponsePolicyApplier.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Optional; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; import uk.co.compendiumdev.thingifier.api.response.RouteApiResponsePolicy; import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; @@ -50,6 +51,29 @@ public ApiResponse apply( final String publicPath, final ApiResponse response, final ResponseViewApplicator responseViewApplicator) { + return apply(verb, publicPath, response, new HttpHeadersBlock(), responseViewApplicator); + } + + /** + * Applies the matching route response policies, including request-aware conditional policies. + * + *

The unconditional status policy runs first, then each matching conditional policy runs in + * declaration order. This lets a route describe its default response shape and then layer + * request-specific adjustments over the top. + * + * @param verb routing verb for route-rule lookup + * @param publicPath public request path + * @param response generated response + * @param requestHeaders request headers used by conditional policies + * @param responseViewApplicator normal route/entity response-view applicator + * @return the same response after policy actions have been applied + */ + public ApiResponse apply( + final RoutingVerb verb, + final String publicPath, + final ApiResponse response, + final HttpHeadersBlock requestHeaders, + final ResponseViewApplicator responseViewApplicator) { if (response == null) { return null; } @@ -59,14 +83,16 @@ public ApiResponse apply( selectedRule .map(rule -> applyResponseShape(rule, publicPath, response)) .orElse(response); - final Optional selectedPolicy = - selectedRule.flatMap(rule -> policyFor(rule, shapedResponse)); + final List selectedPolicies = + selectedRule + .map(rule -> policiesFor(rule, shapedResponse, requestHeaders)) + .orElse(List.of()); - selectedPolicy.ifPresent(policy -> applyStatusAndHeaders(policy, shapedResponse)); + selectedPolicies.forEach(policy -> applyStatusAndHeaders(policy, shapedResponse)); if (responseViewApplicator != null) { responseViewApplicator.apply(shapedResponse); } - selectedPolicy.ifPresent(policy -> applyBodyPolicy(policy, shapedResponse)); + selectedPolicies.forEach(policy -> applyBodyPolicy(policy, shapedResponse)); return shapedResponse; } @@ -77,15 +103,33 @@ private Optional routeRuleFor( .ruleFor(verb, publicPath, runtime.apiConfig().getApiEndPointPrefix()); } - private Optional policyFor( - final ThingifierApiRouteRule rule, final ApiResponse response) { + private List policiesFor( + final ThingifierApiRouteRule rule, + final ApiResponse response, + final HttpHeadersBlock requestHeaders) { + final List policies = new java.util.ArrayList<>(); if (response.isValidationErrorResponse()) { - return rule.validationErrorResponsePolicy(); + rule.validationErrorResponsePolicy() + .filter(policy -> policy.matchesRequest(requestHeaders)) + .ifPresent(policies::add); + return policies; } - if (response.isErrorResponse()) { - return rule.errorResponsePolicyFor(response.getStatusCode()); + if (response.isErrorResponse() || response.getStatusCode() >= 400) { + rule.errorResponsePolicyFor(response.getStatusCode()) + .filter(policy -> policy.matchesRequest(requestHeaders)) + .ifPresent(policies::add); + for (RouteApiResponsePolicy policy : + rule.conditionalErrorResponsePoliciesFor(response.getStatusCode())) { + if (policy.matchesRequest(requestHeaders)) { + policies.add(policy); + } + } + return policies; } - return rule.successResponsePolicy(); + rule.successResponsePolicy() + .filter(policy -> policy.matchesRequest(requestHeaders)) + .ifPresent(policies::add); + return policies; } private ApiResponse applyResponseShape( @@ -199,6 +243,10 @@ private void applyStatusAndHeaders( response.setHeader(header.name(), header.value()); } + for (String headerName : policy.removedHeaders()) { + response.removeHeader(headerName); + } + for (RouteApiResponsePolicy.InstanceFieldHeader header : policy.instanceFieldHeaders()) { returnedFieldValue(response, header.fieldName()) .ifPresent(value -> response.setHeader(header.headerName(), value)); 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 6e5b1987..6609f5a9 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 @@ -611,6 +611,7 @@ private ApiResponse withResponsePolicy( verb, url, responseWithRepository, + context.headers(), apiResponse -> applyResponseEntityView(verb, url, apiResponse)); } 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 0bb914b4..cea5786a 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 @@ -342,6 +342,7 @@ private HttpApiResponse httpResponseFor( routingVerbFor(effectiveVerb), request.getPath(), apiResponse, + request.getHeaders(), response -> applyResponseEntityView(request, effectiveVerb, response)); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/HttpHeadersBlock.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/HttpHeadersBlock.java index ace4d76c..dca6f8ce 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/HttpHeadersBlock.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/HttpHeadersBlock.java @@ -22,6 +22,18 @@ public void put(String headername, String value) { headers.put(headername.trim().toLowerCase(), valueToAdd); } + /** + * Removes a header using HTTP's case-insensitive header-name rules. + * + * @param headername header name to remove + */ + public void remove(String headername) { + if (headername == null) { + return; + } + headers.remove(headername.trim().toLowerCase()); + } + public String get(String headername) { if (headername == null) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java index 68736f74..41bf6631 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java @@ -191,6 +191,21 @@ public ApiResponse setHeader(final String headername, final String value) { return this; } + /** + * Removes a response header. + * + *

Route-level response policies use this to deliberately hide generated or + * authenticator-provided headers when the public route contract requires a different failure + * shape. + * + * @param headername header name to remove + * @return this response so additional metadata can be chained + */ + public ApiResponse removeHeader(final String headername) { + this.headers.remove(headername); + return this; + } + /** * Returns a response header value. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicy.java index f360804e..83ad5bc8 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicy.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; /** * Declarative response shaping for one route outcome. @@ -28,7 +29,9 @@ public enum BodyAction { private Integer statusCode; private final List staticHeaders; + private final List removedHeaders; private final List instanceFieldHeaders; + private final List requestConditions; private BodyAction bodyAction; private String bodyText; private String entityViewName; @@ -37,7 +40,9 @@ public enum BodyAction { public RouteApiResponsePolicy() { statusCode = null; staticHeaders = new ArrayList<>(); + removedHeaders = new ArrayList<>(); instanceFieldHeaders = new ArrayList<>(); + requestConditions = new ArrayList<>(); bodyAction = BodyAction.PRESERVE; bodyText = null; entityViewName = null; @@ -67,6 +72,22 @@ public RouteApiResponsePolicy header(final String name, final String value) { return this; } + /** + * Removes a response header when this policy applies. + * + *

This is useful for route contracts that need to suppress framework-generated challenge + * headers, such as browser-facing {@code 401} responses that should not trigger a credential + * prompt. + * + * @param name header name + * @return this policy so response actions can be chained + * @throws IllegalArgumentException when the header name is blank + */ + public RouteApiResponsePolicy removeHeader(final String name) { + removedHeaders.add(requireText(name, "header name")); + return this; + } + /** * Adds a header whose value is read from the single returned instance or draft. * @@ -87,6 +108,53 @@ public RouteApiResponsePolicy addInstanceFieldAsHeader( return this; } + /** + * Applies this policy only when the request header has exactly the expected value. + * + *

Multiple request conditions are combined with logical AND. Header names are matched using + * HTTP's case-insensitive rules; values are compared exactly after normal request header + * parsing. + * + * @param headerName request header name + * @param expectedValue expected request header value, with null treated as an empty value + * @return this policy so response actions can be chained + * @throws IllegalArgumentException when the header name is blank + */ + public RouteApiResponsePolicy whenRequestHeader( + final String headerName, final String expectedValue) { + requestConditions.add( + RequestCondition.headerEquals( + requireText(headerName, "header name"), + expectedValue == null ? "" : expectedValue)); + return this; + } + + /** + * Applies this policy only when the request header is present. + * + * @param headerName request header name + * @return this policy so response actions can be chained + * @throws IllegalArgumentException when the header name is blank + */ + public RouteApiResponsePolicy whenRequestHeaderPresent(final String headerName) { + requestConditions.add( + RequestCondition.headerPresent(requireText(headerName, "header name"))); + return this; + } + + /** + * Applies this policy only when the request header is absent. + * + * @param headerName request header name + * @return this policy so response actions can be chained + * @throws IllegalArgumentException when the header name is blank + */ + public RouteApiResponsePolicy whenRequestHeaderMissing(final String headerName) { + requestConditions.add( + RequestCondition.headerMissing(requireText(headerName, "header name"))); + return this; + } + /** * Suppresses the rendered response body while preserving status and headers. * @@ -146,6 +214,15 @@ public List staticHeaders() { return Collections.unmodifiableList(staticHeaders); } + /** + * Returns response headers removed by this policy. + * + * @return immutable header names + */ + public List removedHeaders() { + return Collections.unmodifiableList(removedHeaders); + } + /** * Returns instance-field header actions in declaration order. * @@ -155,6 +232,36 @@ public List instanceFieldHeaders() { return Collections.unmodifiableList(instanceFieldHeaders); } + /** + * Returns request conditions that must match before this policy applies. + * + * @return immutable request conditions + */ + public List requestConditions() { + return Collections.unmodifiableList(requestConditions); + } + + /** + * Reports whether this policy should apply to the supplied request headers. + * + *

A policy with no request conditions matches every request. Conditions are deliberately + * request-only so response policy selection stays deterministic and does not depend on later + * body rendering. + * + * @param requestHeaders request headers from the active API call + * @return true when every configured request condition matches + */ + public boolean matchesRequest(final HttpHeadersBlock requestHeaders) { + final HttpHeadersBlock headers = + requestHeaders == null ? new HttpHeadersBlock() : requestHeaders; + for (RequestCondition condition : requestConditions) { + if (!condition.matches(headers)) { + return false; + } + } + return true; + } + /** * Returns the configured body action. * @@ -218,6 +325,59 @@ public String value() { } } + /** One request-header predicate used to decide if a route response policy should run. */ + public static final class RequestCondition { + private enum Type { + HEADER_EQUALS, + HEADER_PRESENT, + HEADER_MISSING + } + + private final Type type; + private final String headerName; + private final String expectedValue; + + private RequestCondition( + final Type type, final String headerName, final String expectedValue) { + this.type = type; + this.headerName = headerName; + this.expectedValue = expectedValue; + } + + private static RequestCondition headerEquals( + final String headerName, final String expectedValue) { + return new RequestCondition(Type.HEADER_EQUALS, headerName, expectedValue); + } + + private static RequestCondition headerPresent(final String headerName) { + return new RequestCondition(Type.HEADER_PRESENT, headerName, null); + } + + private static RequestCondition headerMissing(final String headerName) { + return new RequestCondition(Type.HEADER_MISSING, headerName, null); + } + + /** + * Reports whether this condition matches the supplied request headers. + * + * @param headers request headers + * @return true when the predicate matches + */ + public boolean matches(final HttpHeadersBlock headers) { + switch (type) { + case HEADER_EQUALS: + return headers.headerExists(headerName) + && headers.get(headerName).equals(expectedValue); + case HEADER_PRESENT: + return headers.headerExists(headerName); + case HEADER_MISSING: + return !headers.headerExists(headerName); + default: + return false; + } + } + } + /** Header action that reads its value from a returned instance or draft field. */ public static final class InstanceFieldHeader { private final String headerName; 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 c285692b..50917a12 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 @@ -47,6 +47,7 @@ public final class ThingifierApiRouteRule { private final List apiOperationValidators; private RouteApiResponsePolicy successResponsePolicy; private final Map errorResponsePolicies; + private final Map> conditionalErrorResponsePolicies; private RouteApiResponsePolicy validationErrorResponsePolicy; private String documentation; private String requestPayload; @@ -81,6 +82,7 @@ public final class ThingifierApiRouteRule { this.apiOperationValidators = new java.util.ArrayList<>(); this.successResponsePolicy = null; this.errorResponsePolicies = new HashMap<>(); + this.conditionalErrorResponsePolicies = new HashMap<>(); this.validationErrorResponsePolicy = null; this.documentation = null; this.requestPayload = null; @@ -754,6 +756,26 @@ public RouteApiResponsePolicy onError(final int statusCode) { statusCode, ignored -> new RouteApiResponsePolicy()); } + /** + * Adds a conditional response policy for generated error responses with one status code. + * + *

Unlike {@link #onError(int)}, each call creates a new policy and appends it to the route's + * ordered conditional policy list. The unconditional {@code onError} policy, when configured, + * runs first; matching conditional policies then run in declaration order. This lets a route + * define a default error shape and request-specific overrides without relying on broad response + * hooks. + * + * @param statusCode generated error status code to match + * @return mutable route response policy for a conditional error outcome + */ + public RouteApiResponsePolicy onErrorWhen(final int statusCode) { + final RouteApiResponsePolicy policy = new RouteApiResponsePolicy(); + conditionalErrorResponsePolicies + .computeIfAbsent(statusCode, ignored -> new java.util.ArrayList<>()) + .add(policy); + return policy; + } + /** * Configures response shaping for Thingifier validation-style failures on this route. * @@ -797,6 +819,17 @@ public Optional errorResponsePolicyFor(final int statusC return Optional.ofNullable(errorResponsePolicies.get(statusCode)); } + /** + * Returns conditional error response policies for a status code in declaration order. + * + * @param statusCode generated error status code + * @return immutable conditional policies + */ + public List conditionalErrorResponsePoliciesFor(final int statusCode) { + return Collections.unmodifiableList( + conditionalErrorResponsePolicies.getOrDefault(statusCode, List.of())); + } + /** * Returns all status-specific error response policies. * @@ -806,6 +839,20 @@ public Map errorResponsePolicies() { return Collections.unmodifiableMap(errorResponsePolicies); } + /** + * Returns all conditional status-specific error response policies. + * + * @return immutable map of error status to ordered conditional policies + */ + public Map> conditionalErrorResponsePolicies() { + final Map> snapshot = new HashMap<>(); + for (Map.Entry> entry : + conditionalErrorResponsePolicies.entrySet()) { + snapshot.put(entry.getKey(), Collections.unmodifiableList(entry.getValue())); + } + return Collections.unmodifiableMap(snapshot); + } + /** * Uses the same entity view for request validation and successful responses on this route. * @@ -1053,6 +1100,13 @@ private void applyResponsePolicyMetadataTo(final RoutingDefinition route) { route.addPossibleStatus(RoutingStatus.returnValue(entry.getKey())); applyPolicyMetadata(route, entry.getValue()); } + for (Map.Entry> entry : + conditionalErrorResponsePolicies.entrySet()) { + route.addPossibleStatus(RoutingStatus.returnValue(entry.getKey())); + for (RouteApiResponsePolicy policy : entry.getValue()) { + applyPolicyMetadata(route, policy); + } + } } private void applyPolicyMetadata( diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicyTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicyTest.java index 2b9eb7ff..7fe135b0 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicyTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/RouteApiResponsePolicyTest.java @@ -5,11 +5,13 @@ import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING; import java.util.List; +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.apihandlers.DefaultThingifierApiRuntime; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteApiResponsePolicyApplier; +import uk.co.compendiumdev.thingifier.adapter.http.messagehooks.HttpApiResponseHook; import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; @@ -18,6 +20,8 @@ import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticationResult; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizationResult; import uk.co.compendiumdev.thingifier.api.spec.ResponseShape; import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; import uk.co.compendiumdev.thingifier.core.EntityRelModel; @@ -146,6 +150,217 @@ void errorPolicyCanSuppressEarlyAcceptHeaderResponseBody() { Assertions.assertEquals("", response.getBody()); } + @Test + void errorPolicyCanRemoveGeneratedAuthChallengeHeader() { + final Thingifier thingifier = secretModel(); + protectSecretTokenWithBearer(thingifier).onError(401).removeHeader("WWW-Authenticate"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/token")); + + Assertions.assertEquals(401, response.getStatusCode()); + Assertions.assertFalse(response.getHeaders().headerExists("WWW-Authenticate")); + } + + @Test + void conditionalExactHeaderPolicyAppliesAfterDefaultErrorPolicy() { + final Thingifier thingifier = secretModel(); + final ThingifierApiRouteRule route = protectSecretTokenWithBasic(thingifier); + route.onError(401) + .header("WWW-Authenticate", "Basic realm=\"User Visible Realm\"") + .bodyText("default body"); + route.onErrorWhen(401) + .whenRequestHeader("X-Embedded-Client", "true") + .removeHeader("WWW-Authenticate") + .suppressBody(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .get(jsonRequest("/secret/token").addHeader("X-Embedded-Client", "true")); + + Assertions.assertEquals(401, response.getStatusCode()); + Assertions.assertFalse(response.getHeaders().headerExists("WWW-Authenticate")); + Assertions.assertEquals("", response.getBody()); + } + + @Test + void conditionalExactHeaderPolicyIsSkippedWhenHeaderValueDiffers() { + final Thingifier thingifier = secretModel(); + final ThingifierApiRouteRule route = protectSecretTokenWithBasic(thingifier); + route.onError(401) + .header("WWW-Authenticate", "Basic realm=\"User Visible Realm\"") + .bodyText("default body"); + route.onErrorWhen(401) + .whenRequestHeader("X-Embedded-Client", "true") + .removeHeader("WWW-Authenticate") + .suppressBody(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .get(jsonRequest("/secret/token").addHeader("X-Embedded-Client", "false")); + + Assertions.assertEquals(401, response.getStatusCode()); + Assertions.assertEquals( + "Basic realm=\"User Visible Realm\"", + response.getHeaders().get("WWW-Authenticate")); + Assertions.assertEquals("default body", response.getBody()); + } + + @Test + void conditionalHeaderPresentPolicyAppliesWhenHeaderExists() { + final Thingifier thingifier = secretModel(); + final ThingifierApiRouteRule route = getSecretNoteRoute(thingifier); + route.onError(404).bodyText("not found"); + route.onErrorWhen(404).whenRequestHeaderPresent("X-Quiet").suppressBody(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .get(jsonRequest("/secret/note").addHeader("X-Quiet", "true")); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertEquals("", response.getBody()); + } + + @Test + void conditionalHeaderMissingPolicyAppliesWhenHeaderIsAbsent() { + final Thingifier thingifier = secretModel(); + final ThingifierApiRouteRule route = getSecretNoteRoute(thingifier); + route.onError(404).bodyText("not found"); + route.onErrorWhen(404).whenRequestHeaderMissing("X-Debug").bodyText("quiet missing"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertEquals("quiet missing", response.getBody()); + } + + @Test + void multipleConditionalPoliciesApplyInDeclarationOrder() { + final Thingifier thingifier = secretModel(); + final ThingifierApiRouteRule route = getSecretNoteRoute(thingifier); + route.onError(404).header("X-Stage", "default"); + route.onErrorWhen(404).whenRequestHeaderPresent("X-Mode").header("X-Stage", "first"); + route.onErrorWhen(404).whenRequestHeaderPresent("X-Mode").header("X-Stage", "second"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .get(jsonRequest("/secret/note").addHeader("X-Mode", "test")); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertEquals("second", response.getHeaders().get("X-Stage")); + } + + @Test + void errorPolicyRunsForAuthorizerRejection() { + final Thingifier thingifier = secretModel(); + protectSecretTokenWithBearer(thingifier) + .authorizeWith(context -> ThingifierApiAuthorizationResult.forbidden()) + .onError(403) + .suppressBody(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .get(jsonRequest("/secret/token").addHeader("Authorization", "Bearer ok")); + + Assertions.assertEquals(403, response.getStatusCode()); + Assertions.assertEquals("", response.getBody()); + } + + @Test + void conditionalPolicyCanRemoveCustomAuthenticatorRejectionHeader() { + final Thingifier thingifier = secretModel(); + thingifier + .apiSpec() + .authenticator( + "secretBearer", + context -> { + final ApiResponse response = + ApiResponse.error(401, "custom rejection") + .setHeader("WWW-Authenticate", "Custom"); + return ThingifierApiAuthenticationResult.rejected(response); + }); + final ThingifierApiRouteRule route = + getSecretTokenRoute(thingifier).secureWithBearerAuth("secretBearer"); + route.onError(401).header("X-Default-Policy", "applied"); + route.onErrorWhen(401) + .whenRequestHeaderPresent("X-No-Challenge") + .removeHeader("WWW-Authenticate") + .suppressBody(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .get( + jsonRequest("/secret/token") + .addHeader("Authorization", "Bearer rejected") + .addHeader("X-No-Challenge", "true")); + + Assertions.assertEquals(401, response.getStatusCode()); + Assertions.assertFalse(response.getHeaders().headerExists("WWW-Authenticate")); + Assertions.assertEquals("applied", response.getHeaders().get("X-Default-Policy")); + Assertions.assertEquals("", response.getBody()); + } + + @Test + void responseHookReceivesPolicyShapedAuthFailureResponse() { + final Thingifier thingifier = secretModel(); + protectSecretTokenWithBearer(thingifier) + .onError(401) + .removeHeader("WWW-Authenticate") + .suppressBody(); + final AtomicReference observedResponse = new AtomicReference<>(); + final HttpApiResponseHook hook = + (request, response, config) -> { + observedResponse.set(response); + return null; + }; + + new ThingifierHttpApi(thingifier, null, List.of(hook)).get(jsonRequest("/secret/token")); + + Assertions.assertNotNull(observedResponse.get()); + Assertions.assertEquals(401, observedResponse.get().getStatusCode()); + Assertions.assertFalse( + observedResponse.get().getHeaders().headerExists("WWW-Authenticate")); + Assertions.assertEquals("", observedResponse.get().getBody()); + } + + @Test + void directApiAppliesConditionalErrorPolicyUsingRequestHeaders() { + final Thingifier thingifier = secretModel(); + getSecretNoteRoute(thingifier) + .onErrorWhen(404) + .whenRequestHeaderPresent("X-Quiet") + .suppressBody(); + final HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("X-Quiet", "true"); + + final ApiResponse response = + thingifier.api().get("secret/note", new QueryFilterParams(), headers); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertFalse(response.hasABody()); + } + + @Test + void conditionalErrorPolicyHeadersAreAddedToGeneratedRouteDocumentation() { + final Thingifier thingifier = secretModel(); + getSecretTokenRoute(thingifier) + .onErrorWhen(401) + .whenRequestHeaderPresent("X-Client") + .header("WWW-Authenticate", "Bearer"); + + final RoutingDefinition route = + route( + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""), + RoutingVerb.GET, + "secret/token"); + + Assertions.assertTrue( + route.getPossibleStatusReponses().stream() + .anyMatch(status -> status.value() == 401)); + Assertions.assertEquals("Bearer", route.getResponseHeaderValue("WWW-Authenticate")); + } + @Test void directApiAppliesSuccessPolicy() { final Thingifier thingifier = secretModel(); @@ -250,6 +465,25 @@ private ThingifierApiRouteRule postSecretTokenRoute(final Thingifier thingifier) .entityCan(UPDATE); } + private ThingifierApiRouteRule protectSecretTokenWithBearer(final Thingifier thingifier) { + thingifier + .apiSpec() + .authenticator( + "secretBearer", + context -> ThingifierApiAuthenticationResult.authenticated("principal")); + return getSecretTokenRoute(thingifier).secureWithBearerAuth("secretBearer"); + } + + private ThingifierApiRouteRule protectSecretTokenWithBasic(final Thingifier thingifier) { + thingifier.apiSpec().security().basic("secretBasic", "User Visible Realm"); + thingifier + .apiSpec() + .authenticator( + "secretBasic", + context -> ThingifierApiAuthenticationResult.authenticated("principal")); + return getSecretTokenRoute(thingifier).secureWithBasicAuth("secretBasic"); + } + private Thingifier secretModel() { final Thingifier thingifier = new Thingifier();