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();