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
@@ -1,5 +1,6 @@
package uk.co.compendiumdev.thingifier.adapter.http.apihandlers;

import java.util.List;
import java.util.Optional;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
Expand Down Expand Up @@ -53,16 +54,21 @@ public ApiResponse apply(
return null;
}

final Optional<ThingifierApiRouteRule> selectedRule = routeRuleFor(verb, publicPath);
final ApiResponse shapedResponse =
selectedRule
.map(rule -> applyResponseShape(rule, publicPath, response))
.orElse(response);
final Optional<RouteApiResponsePolicy> selectedPolicy =
routeRuleFor(verb, publicPath).flatMap(rule -> policyFor(rule, response));
selectedRule.flatMap(rule -> policyFor(rule, shapedResponse));

selectedPolicy.ifPresent(policy -> applyStatusAndHeaders(policy, response));
selectedPolicy.ifPresent(policy -> applyStatusAndHeaders(policy, shapedResponse));
if (responseViewApplicator != null) {
responseViewApplicator.apply(response);
responseViewApplicator.apply(shapedResponse);
}
selectedPolicy.ifPresent(policy -> applyBodyPolicy(policy, response));
selectedPolicy.ifPresent(policy -> applyBodyPolicy(policy, shapedResponse));

return response;
return shapedResponse;
}

private Optional<ThingifierApiRouteRule> routeRuleFor(
Expand All @@ -82,6 +88,107 @@ private Optional<RouteApiResponsePolicy> policyFor(
return rule.successResponsePolicy();
}

private ApiResponse applyResponseShape(
final ThingifierApiRouteRule rule,
final String publicPath,
final ApiResponse response) {
if (!rule.hasResponseShapeOverride()
|| response.isErrorResponse()
|| response.hasABodyOverride()
|| response.getStatusCode() < 200
|| response.getStatusCode() >= 300) {
return response;
}
Comment on lines +95 to +101

switch (rule.responseShape()) {
case SINGLE_INSTANCE:
return singleInstanceResponse(rule, publicPath, response);
case COLLECTION:
return collectionResponse(rule, publicPath, response);
Comment on lines +106 to +107

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 Document collection-shaped fixed responses as collections

When a fixed route uses ResponseShape.COLLECTION, this branch makes the runtime response a collection, but OpenAPI generation never consumes RoutingDefinition.responseShape(): ThingifierApiSpec.applyFixedRoutePayloads() still registers the singular entity payload for successful fixed routes, and Swaggerizer derives its schema from that payload name. Generated clients therefore expect an object while the server returns the collection wrapper, so the documented payload/schema also needs to honor this shape.

Useful? React with 👍 / 👎.

case DEFAULT:
default:
return response;
}
}

private ApiResponse singleInstanceResponse(
final ThingifierApiRouteRule rule,
final String publicPath,
final ApiResponse response) {
if (!rule.hasFixedIdentifierMapping()) {
return ApiResponse.error(
500,
Comment on lines +118 to +120

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 Handle generated instance routes before rejecting the shape

When respondWithSingleInstance() is placed on a normal generated instance rule—the added ThingifierApiSpecTest does this for /api/todos/{id}—a successful request returns 500 solely because the rule has no fixed mapping, even when response.hasReturnedInstance() is already true. This turns an otherwise valid generated GET into a server error; accept an already-satisfied singleton response before enforcing fixed-route conversion, or reject this configuration during specification setup.

Useful? React with 👍 / 👎.

String.format(
"Route %s is configured for a single instance response but is not a fixed identifier route",
publicPath));
}
if (response.hasReturnedInstance()) {
return response;
}
if (response.hasReturnedDraft()) {
return ApiResponse.error(
500,
String.format(
"Route %s is configured for a single instance response but returned a draft instance",
publicPath));
}
if (!response.isCollection()) {
return response;
}

final boolean hadBody = response.hasABody();
final List<EntityInstance> instances = response.getReturnedInstanceCollection();
if (instances.isEmpty()) {
return ApiResponse.error404(
String.format("Could not find an instance with %s", publicPath));
}
if (instances.size() > 1) {
return ApiResponse.error(
500,
String.format(
"Route %s is configured for a single instance response but returned %d instances",
publicPath, instances.size()));
}
return preserveBodyPresence(response.returnSingleInstance(instances.get(0)), hadBody);
}

private ApiResponse collectionResponse(
final ThingifierApiRouteRule rule,
final String publicPath,
final ApiResponse response) {
if (!rule.hasFixedIdentifierMapping()) {
return ApiResponse.error(
500,
String.format(
"Route %s is configured for a collection response but is not a fixed identifier route",
publicPath));
}
if (response.isCollection()) {
return response;
}
if (response.hasReturnedInstance()) {
final boolean hadBody = response.hasABody();
return preserveBodyPresence(
response.returnInstanceCollection(List.of(response.getReturnedInstance())),
hadBody);
}
if (response.hasReturnedDraft()) {
return ApiResponse.error(
500,
String.format(
"Route %s is configured for a collection response but returned a draft instance",
publicPath));
}
return response;
}

private ApiResponse preserveBodyPresence(final ApiResponse response, final boolean hadBody) {
if (!hadBody) {
response.clearBody();
}
return response;
}

private void applyStatusAndHeaders(
final RouteApiResponsePolicy policy, final ApiResponse response) {
if (policy.statusCode() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import uk.co.compendiumdev.thingifier.api.security.SecuritySchemeNames;
import uk.co.compendiumdev.thingifier.api.security.ThingifierApiSecuritySpec;
import uk.co.compendiumdev.thingifier.api.spec.FixedResourcePolicy;
import uk.co.compendiumdev.thingifier.api.spec.ResponseShape;
import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition;
import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field;

Expand Down Expand Up @@ -47,6 +48,7 @@ public class RoutingDefinition {
private boolean disabled = false;
private String requestEntityViewName;
private HashMap<Integer, String> responseEntityViewNames;
private ResponseShape responseShape;
private String fixedEntityName;
private String fixedIdentifier;
private FixedResourcePolicy fixedResourcePolicy;
Expand Down Expand Up @@ -82,6 +84,7 @@ public RoutingDefinition(
responseHeaders = new HashMap<>();
requestEntityViewName = null;
responseEntityViewNames = new HashMap<>();
responseShape = ResponseShape.DEFAULT;
authSchemeNames = new ArrayList<>();
fixedEntityName = null;
fixedIdentifier = null;
Expand Down Expand Up @@ -490,6 +493,38 @@ public String getResponseEntityViewFor(final int statusCode) {
return responseEntityViewNames.get(statusCode);
}

/**
* Sets the successful entity response shape documented for this route.
*
* <p>This metadata lets route rules describe singleton/fixed-resource contracts explicitly
* without changing how ordinary generated collection routes are documented.
*
* @param shape response shape, or {@link ResponseShape#DEFAULT} when null
* @return this definition so route metadata can be chained
*/
public RoutingDefinition responseShape(final ResponseShape shape) {
responseShape = shape == null ? ResponseShape.DEFAULT : shape;
return this;
}

/**
* Returns the successful entity response shape for this route.
*
* @return route response shape, defaulting to {@link ResponseShape#DEFAULT}
*/
public ResponseShape responseShape() {
return responseShape;
}

/**
* Reports whether the route explicitly overrides normal generated response shape.
*
* @return true when a non-default response shape is configured
*/
public boolean hasResponseShapeOverride() {
return responseShape != ResponseShape.DEFAULT;
}

/**
* Marks this public route as mapping to one fixed entity instance.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package uk.co.compendiumdev.thingifier.api.spec;

/**
* Declares how a route wants successful entity responses to be represented.
*
* <p>Thingifier normally derives response shape from the generated route and legacy API
* configuration. Route-level response shape is an explicit public contract override for endpoints
* such as fixed-resource routes where the URL represents one known instance even though the
* generated model still supports collection-style access elsewhere.
*/
public enum ResponseShape {
/** Preserve the shape that normal generated Thingifier routing would have produced. */
DEFAULT,

/** Require the successful response to contain one persisted entity instance. */
SINGLE_INSTANCE,

/** Require the successful response to be represented as a collection of entity instances. */
COLLECTION
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public final class ThingifierApiRouteRule {
private String requestEntityView;
private String defaultEntityView;
private Map<Integer, String> responseEntityViews;
private ResponseShape responseShape;
private String mappedEntityName;
private String fixedIdentifier;
private FixedResourcePolicy fixedResourcePolicy;
Expand Down Expand Up @@ -86,6 +87,7 @@ public final class ThingifierApiRouteRule {
this.requestEntityView = null;
this.defaultEntityView = null;
this.responseEntityViews = new HashMap<>();
this.responseShape = ResponseShape.DEFAULT;
this.mappedEntityName = null;
this.fixedIdentifier = null;
this.fixedResourcePolicy = FixedResourcePolicy.RETURN_404;
Expand Down Expand Up @@ -545,6 +547,34 @@ public ThingifierApiRouteRule defaultEntityView(final String viewName) {
return this;
}

/**
* Sets the successful entity response shape for this route.
*
* <p>Use {@link ResponseShape#SINGLE_INSTANCE} for fixed-resource routes where the public URL
* represents one known entity instance and must render as one object, even when legacy global
* configuration would normally wrap instance reads in a collection response.
*
* @param shape route response shape, or {@link ResponseShape#DEFAULT} when null
* @return this rule so route API configuration can be chained
*/
public ThingifierApiRouteRule responseShape(final ResponseShape shape) {
this.responseShape = shape == null ? ResponseShape.DEFAULT : shape;
return this;
}

/**
* Requires successful responses on this route to render as one persisted instance.
*
* <p>This is a convenience alias for {@code responseShape(ResponseShape.SINGLE_INSTANCE)}. It
* is intended for fixed-resource routes such as {@code /secret/note} where the fixed identifier
* is declared in server code rather than supplied by the URL.
*
* @return this rule so route API configuration can be chained
*/
public ThingifierApiRouteRule respondWithSingleInstance() {
return responseShape(ResponseShape.SINGLE_INSTANCE);
}

/**
* Declares the model entity that a non-generated public route should target.
*
Expand Down Expand Up @@ -827,6 +857,24 @@ public String responseEntityViewFor(final int statusCode) {
return null;
}

/**
* Returns the successful response shape configured for this route.
*
* @return route response shape, defaulting to {@link ResponseShape#DEFAULT}
*/
public ResponseShape responseShape() {
return responseShape;
}

/**
* Reports whether this route overrides the generated response shape.
*
* @return true when a non-default response shape is configured
*/
public boolean hasResponseShapeOverride() {
return responseShape != ResponseShape.DEFAULT;
}

/**
* Reports whether this route overrides entity write operation policy.
*
Expand Down Expand Up @@ -987,6 +1035,9 @@ void applyTo(final RoutingDefinition route) {
if (hasFixedIdentifierMapping()) {
route.mapToFixedEntity(mappedEntityName, fixedIdentifier, fixedResourcePolicy);
}
if (hasResponseShapeOverride()) {
route.responseShape(responseShape);
}
applyResponsePolicyMetadataTo(route);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
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.List;
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.api.docgen.ApiRoutingDefinition;
import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator;
import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition;
Expand All @@ -15,6 +18,7 @@
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.spec.ResponseShape;
import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule;
import uk.co.compendiumdev.thingifier.core.EntityRelModel;
import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition;
Expand Down Expand Up @@ -159,6 +163,44 @@ void directApiAppliesSuccessPolicy() {
Assertions.assertEquals("issued-value", response.getHeaders().get("X-Secret-Token"));
}

@Test
void singleInstanceShapeRejectsMultipleReturnedInstances() {
final Thingifier thingifier = secretModel();
getSecretNoteRoute(thingifier).respondWithSingleInstance();
final EntityInstance first = createSecretNote(thingifier, "note", "first", "internal");
final EntityInstance second = createSecretNote(thingifier, "other", "second", "internal");

final ApiResponse response =
new RouteApiResponsePolicyApplier(new DefaultThingifierApiRuntime(thingifier))
.apply(
RoutingVerb.GET,
"secret/note",
ApiResponse.success()
.returnInstanceCollection(List.of(first, second)),
null);

Assertions.assertEquals(500, response.getStatusCode());
Assertions.assertTrue(response.isErrorResponse());
Assertions.assertTrue(
response.getErrorMessages()
.contains(
"Route secret/note is configured for a single instance response but returned 2 instances"));
}

@Test
void collectionShapeWrapsSingleReturnedInstanceForFixedRoute() {
final Thingifier thingifier = secretModel();
getSecretNoteRoute(thingifier).responseShape(ResponseShape.COLLECTION);
createSecretNote(thingifier, "note", "visible", "internal");

final HttpApiResponse response =
new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note"));

Assertions.assertEquals(200, response.getStatusCode());
Assertions.assertTrue(response.apiResponse().isCollection());
Assertions.assertTrue(response.getBody().contains("secretnotes"));
}

@Test
void responsePolicyHeadersAreAddedToGeneratedRouteDocumentation() {
final Thingifier thingifier = secretModel();
Expand Down
Loading
Loading