Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>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;
}
Expand All @@ -59,14 +83,16 @@ public ApiResponse apply(
selectedRule
.map(rule -> applyResponseShape(rule, publicPath, response))
.orElse(response);
final Optional<RouteApiResponsePolicy> selectedPolicy =
selectedRule.flatMap(rule -> policyFor(rule, shapedResponse));
final List<RouteApiResponsePolicy> 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore structured bodies for later entity-view policies

When an unconditional error policy uses bodyText(...) or suppressBody() and a matching onErrorWhen(...) policy later selects bodyUsingEntityView(...), applying every body action sequentially leaves the earlier explicit/cleared body in place: applyEntityView only changes the view and neither clears the text override nor restores hasBody. This can occur with a custom authenticator rejection carrying an entity response, and the conditional policy then fails to override the default as the documented layering promises, returning the default text or an empty body instead of the viewed entity.

Useful? React with 👍 / 👎.


return shapedResponse;
}
Expand All @@ -77,15 +103,33 @@ private Optional<ThingifierApiRouteRule> routeRuleFor(
.ruleFor(verb, publicPath, runtime.apiConfig().getApiEndPointPrefix());
}

private Optional<RouteApiResponsePolicy> policyFor(
final ThingifierApiRouteRule rule, final ApiResponse response) {
private List<RouteApiResponsePolicy> policiesFor(
final ThingifierApiRouteRule rule,
final ApiResponse response,
final HttpHeadersBlock requestHeaders) {
final List<RouteApiResponsePolicy> 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(
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@ private ApiResponse withResponsePolicy(
verb,
url,
responseWithRepository,
context.headers(),
apiResponse -> applyResponseEntityView(verb, url, apiResponse));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ private HttpApiResponse httpResponseFor(
routingVerbFor(effectiveVerb),
request.getPath(),
apiResponse,
request.getHeaders(),
response ->
applyResponseEntityView(request, effectiveVerb, response));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,21 @@ public ApiResponse setHeader(final String headername, final String value) {
return this;
}

/**
* Removes a response header.
*
* <p>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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,7 +29,9 @@ public enum BodyAction {

private Integer statusCode;
private final List<HeaderValue> staticHeaders;
private final List<String> removedHeaders;
private final List<InstanceFieldHeader> instanceFieldHeaders;
private final List<RequestCondition> requestConditions;
private BodyAction bodyAction;
private String bodyText;
private String entityViewName;
Expand All @@ -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;
Expand Down Expand Up @@ -67,6 +72,22 @@ public RouteApiResponsePolicy header(final String name, final String value) {
return this;
}

/**
* Removes a response header when this policy applies.
*
* <p>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.
*
Expand All @@ -87,6 +108,53 @@ public RouteApiResponsePolicy addInstanceFieldAsHeader(
return this;
}

/**
* Applies this policy only when the request header has exactly the expected value.
*
* <p>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.
*
Expand Down Expand Up @@ -146,6 +214,15 @@ public List<HeaderValue> staticHeaders() {
return Collections.unmodifiableList(staticHeaders);
}

/**
* Returns response headers removed by this policy.
*
* @return immutable header names
*/
public List<String> removedHeaders() {
return Collections.unmodifiableList(removedHeaders);
}

/**
* Returns instance-field header actions in declaration order.
*
Expand All @@ -155,6 +232,36 @@ public List<InstanceFieldHeader> instanceFieldHeaders() {
return Collections.unmodifiableList(instanceFieldHeaders);
}

/**
* Returns request conditions that must match before this policy applies.
*
* @return immutable request conditions
*/
public List<RequestCondition> requestConditions() {
return Collections.unmodifiableList(requestConditions);
}

/**
* Reports whether this policy should apply to the supplied request headers.
*
* <p>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;
}
Comment on lines +254 to +263

/**
* Returns the configured body action.
*
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading