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 001ae7b5..a43777e0 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 @@ -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; @@ -53,16 +54,21 @@ public ApiResponse apply( return null; } + final Optional selectedRule = routeRuleFor(verb, publicPath); + final ApiResponse shapedResponse = + selectedRule + .map(rule -> applyResponseShape(rule, publicPath, response)) + .orElse(response); final Optional 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 routeRuleFor( @@ -82,6 +88,107 @@ private Optional 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; + } + + switch (rule.responseShape()) { + case SINGLE_INSTANCE: + return singleInstanceResponse(rule, publicPath, response); + case COLLECTION: + return collectionResponse(rule, publicPath, response); + case DEFAULT: + default: + return response; + } + } + + private ApiResponse singleInstanceResponse( + final ThingifierApiRouteRule rule, + final String publicPath, + final ApiResponse response) { + if (!rule.hasFixedIdentifierMapping()) { + return ApiResponse.error( + 500, + 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 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) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java index 933e11b7..516e8fd7 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java @@ -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; @@ -47,6 +48,7 @@ public class RoutingDefinition { private boolean disabled = false; private String requestEntityViewName; private HashMap responseEntityViewNames; + private ResponseShape responseShape; private String fixedEntityName; private String fixedIdentifier; private FixedResourcePolicy fixedResourcePolicy; @@ -82,6 +84,7 @@ public RoutingDefinition( responseHeaders = new HashMap<>(); requestEntityViewName = null; responseEntityViewNames = new HashMap<>(); + responseShape = ResponseShape.DEFAULT; authSchemeNames = new ArrayList<>(); fixedEntityName = null; fixedIdentifier = null; @@ -490,6 +493,38 @@ public String getResponseEntityViewFor(final int statusCode) { return responseEntityViewNames.get(statusCode); } + /** + * Sets the successful entity response shape documented for this route. + * + *

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. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ResponseShape.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ResponseShape.java new file mode 100644 index 00000000..9d8c4427 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ResponseShape.java @@ -0,0 +1,20 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +/** + * Declares how a route wants successful entity responses to be represented. + * + *

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 +} 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 38670a6a..c285692b 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 @@ -53,6 +53,7 @@ public final class ThingifierApiRouteRule { private String requestEntityView; private String defaultEntityView; private Map responseEntityViews; + private ResponseShape responseShape; private String mappedEntityName; private String fixedIdentifier; private FixedResourcePolicy fixedResourcePolicy; @@ -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; @@ -545,6 +547,34 @@ public ThingifierApiRouteRule defaultEntityView(final String viewName) { return this; } + /** + * Sets the successful entity response shape for this route. + * + *

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. + * + *

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. * @@ -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. * @@ -987,6 +1035,9 @@ void applyTo(final RoutingDefinition route) { if (hasFixedIdentifierMapping()) { route.mapToFixedEntity(mappedEntityName, fixedIdentifier, fixedResourcePolicy); } + if (hasResponseShapeOverride()) { + route.responseShape(responseShape); + } applyResponsePolicyMetadataTo(route); } 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 3f2dbe2e..2b9eb7ff 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 @@ -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; @@ -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; @@ -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(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiFixedRouteTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiFixedRouteTest.java index 29c4b599..52d44683 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiFixedRouteTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiFixedRouteTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleHookRegistry; +import uk.co.compendiumdev.thingifier.adapter.http.messagehooks.HttpApiResponseHook; import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteRegistry; import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteVerb; import uk.co.compendiumdev.thingifier.adapter.httpserver.ThingifierHttpApiRoutings; @@ -174,6 +175,35 @@ void openApiDocumentsFixedRouteWithoutIdentifierParameter() { Assertions.assertTrue(get.getParameters() == null || get.getParameters().isEmpty()); } + @Test + void openApiDocumentsSingleInstanceSchemaForShapedFixedRoute() { + final Thingifier thingifier = secretModel(); + thingifier + .apiSpec() + .route(RoutingVerb.GET, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note") + .defaultEntityView("SecretNoteResponse") + .respondWithSingleInstance(); + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setThingifier(thingifier); + apiDefn.setPathPrefix("/api"); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + final String schemaRef = + openApi.getPaths() + .get("/api/secret/note") + .getGet() + .getResponses() + .get("200") + .getContent() + .get("application/json") + .getSchema() + .get$ref(); + + Assertions.assertEquals("#/components/schemas/SecretNoteResponse", schemaRef); + } + @Test void getFixedRouteReturnsConfiguredInstance() { final Thingifier thingifier = secretModel(); @@ -189,6 +219,61 @@ void getFixedRouteReturnsConfiguredInstance() { Assertions.assertTrue(response.getBody().contains("stored text")); } + @Test + void fixedRouteWithoutResponseShapeHonoursLegacyCollectionConfig() { + final Thingifier thingifier = secretModel(); + thingifier.apiConfig().setReturnSingleGetItemsAsCollection(true); + getSecretNoteRoute(thingifier); + createSecretNote(thingifier, "note", "stored text", "internal-token"); + + 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 singleInstanceShapeOverridesLegacyCollectionConfigForFixedGet() { + final Thingifier thingifier = secretModel(); + thingifier.apiConfig().setReturnSingleGetItemsAsCollection(true); + getSecretNoteRoute(thingifier).respondWithSingleInstance(); + createSecretNote(thingifier, "note", "stored text", "internal-token"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.apiResponse().hasReturnedInstance()); + Assertions.assertFalse(response.apiResponse().isCollection()); + Assertions.assertFalse(response.getBody().contains("secretnotes")); + Assertions.assertTrue(response.getBody().contains("stored text")); + } + + @Test + void responseHookReceivesSingleInstanceBodyForShapedFixedRoute() { + final Thingifier thingifier = secretModel(); + thingifier.apiConfig().setReturnSingleGetItemsAsCollection(true); + getSecretNoteRoute(thingifier).respondWithSingleInstance(); + createSecretNote(thingifier, "note", "stored text", "internal-token"); + final AtomicReference bodySeenByHook = new AtomicReference<>(); + final HttpApiResponseHook hook = + (request, response, config) -> { + bodySeenByHook.set(response.getBody()); + return null; + }; + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier, null, List.of(hook)) + .get(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertNotNull(bodySeenByHook.get()); + Assertions.assertFalse(bodySeenByHook.get().contains("secretnotes")); + Assertions.assertTrue(bodySeenByHook.get().contains("stored text")); + } + @Test void routeResponseViewHidesInternalFieldsForFixedRoute() { final Thingifier thingifier = secretModel(); @@ -254,6 +339,24 @@ void headUsesFixedGetRouteWhenHeadIsNotDeclared() { Assertions.assertEquals("", response.getBody()); } + @Test + void headFixedRouteUsesSingleInstanceShapeBeforeSuccessPolicy() { + final Thingifier thingifier = secretModel(); + thingifier.apiConfig().setReturnSingleGetItemsAsCollection(true); + getSecretNoteRoute(thingifier) + .respondWithSingleInstance() + .onSuccess() + .addInstanceFieldAsHeader("X-Secret-Note", "text"); + createSecretNote(thingifier, "note", "stored text", "internal-token"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).head(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("", response.getBody()); + Assertions.assertEquals("stored text", response.getHeaders().get("X-Secret-Note")); + } + @Test void missingFixedRouteReturns404ByDefault() { final Thingifier thingifier = secretModel(); @@ -280,6 +383,22 @@ void postFixedRouteUpdatesConfiguredInstance() { "after", secretNote(thingifier, "note").getFieldValue("text").asString()); } + @Test + void postFixedRouteWithSingleInstanceShapeReturnsUpdatedInstance() { + final Thingifier thingifier = secretModel(); + postSecretNoteRoute(thingifier).respondWithSingleInstance(); + createSecretNote(thingifier, "note", "before", "internal-token"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .post(jsonPost("/secret/note", "{\"text\":\"after\"}")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.apiResponse().hasReturnedInstance()); + Assertions.assertFalse(response.apiResponse().isCollection()); + Assertions.assertTrue(response.getBody().contains("after")); + } + @Test void postMissingFixedRouteReturns404ByDefault() { final Thingifier thingifier = secretModel(); @@ -396,6 +515,26 @@ void ensureExistsCreatesMissingFixedResource() { Assertions.assertEquals("note", secretNote(thingifier, "note").getPrimaryKeyValue()); } + @Test + void singleInstanceShapeHonoursEnsureExistsFixedRoutePolicy() { + final Thingifier thingifier = secretModel(); + thingifier.apiConfig().setReturnSingleGetItemsAsCollection(true); + thingifier + .apiSpec() + .route(RoutingVerb.GET, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note", FixedResourcePolicy.ENSURE_EXISTS) + .respondWithSingleInstance(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.apiResponse().hasReturnedInstance()); + Assertions.assertEquals( + "note", response.apiResponse().getReturnedInstance().getPrimaryKeyValue()); + } + @Test void generatedInstanceRouteStillWorksAlongsideFixedRoute() { final Thingifier thingifier = secretModel(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java index e50a301d..d6df3a75 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java @@ -133,6 +133,35 @@ void alternativeAuthRouteRecordsOrderedRuntimeSchemes() { Assertions.assertFalse(rule.hasApiKeyAuthEnforcement()); } + @Test + void routeRecordsSingleInstanceResponseShape() { + final Thingifier thingifier = model(); + final ThingifierApiRouteRule rule = + thingifier + .apiSpec() + .route(RoutingVerb.GET, "/api/todos/{id}") + .respondWithSingleInstance(); + + Assertions.assertTrue(rule.hasResponseShapeOverride()); + Assertions.assertEquals(ResponseShape.SINGLE_INSTANCE, rule.responseShape()); + } + + @Test + void routeResponseShapeIsCopiedToGeneratedRouteDefinition() { + final Thingifier thingifier = model(); + thingifier + .apiSpec() + .route(RoutingVerb.GET, "/api/todos/{id}") + .responseShape(ResponseShape.SINGLE_INSTANCE); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + final RoutingDefinition route = route(definition, RoutingVerb.GET, "api/todos/:id"); + + Assertions.assertTrue(route.hasResponseShapeOverride()); + Assertions.assertEquals(ResponseShape.SINGLE_INSTANCE, route.responseShape()); + } + @Test void namedBasicAuthClearsBearerEnforcementOnRoute() { final Thingifier thingifier = model();