diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierAutoDocGenRouting.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierAutoDocGenRouting.java index f5d858d5..669f2891 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierAutoDocGenRouting.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierAutoDocGenRouting.java @@ -122,9 +122,7 @@ public ThingifierAutoDocGenRouting( System.out.println( "Possibly incomplete swagger generation, api not defined from model"); } - if (config.includeMethodNotAllowedEndpoints && !config.includeFieldValidation) { - nameprefix = nameprefix + "permissive-"; - } + nameprefix = nameprefix + variantFilenamePrefix(config); response.header("Content-Type", "application/octet-stream"); response.header( "Content-Disposition", @@ -152,11 +150,7 @@ private void registerOpenApiEndpoint( response.header( "Content-Disposition", "attachment; filename=\"%s\"" - .formatted( - openApiDownloadFilename( - path, - config.includeMethodNotAllowedEndpoints - && !config.includeFieldValidation))); + .formatted(openApiDownloadFilename(path, config))); } return new Swaggerizer(apiDefn) .asJsonWithPreferredServer(config, HttpRequestOrigin.from(request)); @@ -192,11 +186,26 @@ private SwaggerGenerationConfig.PathParameterPlacement pathParameterPlacement( return SwaggerGenerationConfig.PathParameterPlacement.PATH; } - private String openApiDownloadFilename(final String path, final boolean permissive) { + private String openApiDownloadFilename( + final String path, final SwaggerGenerationConfig config) { final String filename = path.substring(path.lastIndexOf("/") + 1); - if (permissive) { - return "permissive-" + filename; + return variantFilenamePrefix(config) + filename; + } + + private String variantFilenamePrefix(final SwaggerGenerationConfig config) { + final StringBuilder prefix = new StringBuilder(); + + if (config.includeMethodNotAllowedEndpoints) { + prefix.append("permissive-"); } - return filename; + if (config.strongSchemas) { + prefix.append("strong-"); + } + if (config.pathParameterPlacement + == SwaggerGenerationConfig.PathParameterPlacement.OPERATION) { + prefix.append("operational-"); + } + + return prefix.toString(); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinServerResponse.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinServerResponse.java index 83bb2e8c..549cad57 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinServerResponse.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinServerResponse.java @@ -1,6 +1,7 @@ package uk.co.compendiumdev.thingifier.adapter.javalin; import io.javalin.http.Context; +import io.javalin.http.HandlerType; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -28,6 +29,9 @@ public String body() { public void body(final String body) { bodySet = true; context.attribute(RESPONSE_BODY_ATTRIBUTE, body == null ? "" : body); + if (context.method() == HandlerType.HEAD) { + return; + } context.result((body == null ? "" : body).getBytes(StandardCharsets.UTF_8)); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java index 55f259df..32f28ec2 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java @@ -185,7 +185,7 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { optionsDocumentationFor(pluralUrl), RoutingVerb.OPTIONS, pluralUrl, - RoutingStatus.returnValue(204, "the endpoint verb options"), + RoutingStatus.returnValue(200, "the endpoint verb options"), new ResponseHeader("Allow", "OPTIONS, GET, HEAD, POST, QUERY")); // the following are not handled so return 405 @@ -357,7 +357,7 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { optionsDocumentationFor(aUrlWGuid), RoutingVerb.OPTIONS, aUrlWGuid, - RoutingStatus.returnValue(204), + RoutingStatus.returnValue(200), new ResponseHeader("Allow", "OPTIONS, GET, HEAD, POST, PUT, DELETE")) .addRequestUrlParam(entityDefn.getField(uniqueIdFieldName)); 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 85a9ddd4..e9f28ba1 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 @@ -1,9 +1,15 @@ package uk.co.compendiumdev.thingifier.api.docgen; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.ContentTypeHeaderParser; import uk.co.compendiumdev.thingifier.api.response.ResponseHeader; import uk.co.compendiumdev.thingifier.api.security.SecuritySchemeNames; @@ -33,6 +39,9 @@ public class RoutingDefinition { private String requestPayload; private List requestContentTypes; private List requestUrlParams; + private List requestQueryParams; + private Set responseStatusesWithSuppressedBodies; + private Map> customResponseContent; private HashMap customHeaders; private HashMap responseHeaders; private boolean usesBasicAuth = false; @@ -77,6 +86,9 @@ public RoutingDefinition( filterableEntityDefn = null; possibleStatusResponses = new ArrayList<>(); requestUrlParams = new ArrayList<>(); + requestQueryParams = new ArrayList<>(); + responseStatusesWithSuppressedBodies = new HashSet<>(); + customResponseContent = new LinkedHashMap<>(); returnPayload = new HashMap<>(); requestPayload = null; requestContentTypes = new ArrayList<>(); @@ -180,6 +192,10 @@ public RoutingDefinition copyWithUrlAndDocumentation( copy.requestPayload = requestPayload; copy.requestContentTypes = new ArrayList<>(requestContentTypes); copy.requestUrlParams = new ArrayList<>(requestUrlParams); + copy.requestQueryParams = new ArrayList<>(requestQueryParams); + copy.responseStatusesWithSuppressedBodies = + new HashSet<>(responseStatusesWithSuppressedBodies); + copy.customResponseContent = copyCustomResponseContent(); copy.customHeaders = new HashMap<>(customHeaders); copy.responseHeaders = new HashMap<>(responseHeaders); copy.usesBasicAuth = usesBasicAuth; @@ -403,6 +419,91 @@ public RoutingDefinition clearReturnPayloads() { return this; } + /** + * Adds response content schema metadata for a specific status and media type. + * + * @param statusCode response status code + * @param mediaType response media type + * @param schema OpenAPI schema to document for the response content + * @return this definition so route metadata can be chained + */ + public RoutingDefinition responseSchema( + final int statusCode, final String mediaType, final Schema schema) { + if (schema == null || isBlank(mediaType)) { + return this; + } + responseContentFor(statusCode, mediaType).schema = schema; + return this; + } + + /** + * Adds response content schema-ref metadata for a specific status and media type. + * + * @param statusCode response status code + * @param mediaType response media type + * @param schemaRef OpenAPI schema reference + * @return this definition so route metadata can be chained + */ + public RoutingDefinition responseSchemaRef( + final int statusCode, final String mediaType, final String schemaRef) { + if (isBlank(schemaRef)) { + return this; + } + final Schema schema = new Schema<>(); + schema.set$ref(schemaRef.trim()); + return responseSchema(statusCode, mediaType, schema); + } + + /** + * Adds string response content schema metadata for a specific status and media type. + * + * @param statusCode response status code + * @param mediaType response media type + * @return this definition so route metadata can be chained + */ + public RoutingDefinition responseStringSchema(final int statusCode, final String mediaType) { + return responseSchema(statusCode, mediaType, new StringSchema()); + } + + /** + * Adds a response example for a specific status and media type. + * + * @param statusCode response status code + * @param mediaType response media type + * @param example example object to serialize in OpenAPI + * @return this definition so route metadata can be chained + */ + public RoutingDefinition responseExample( + final int statusCode, final String mediaType, final Object example) { + if (isBlank(mediaType)) { + return this; + } + responseContentFor(statusCode, mediaType).example = example; + return this; + } + + /** + * Reports whether explicit response content exists for a status. + * + * @param statusCode response status code + * @return true when content metadata exists + */ + public boolean hasResponseContentFor(final int statusCode) { + return customResponseContent.containsKey(statusCode) + && !customResponseContent.get(statusCode).isEmpty(); + } + + /** + * Returns explicit response content metadata for a status. + * + * @param statusCode response status code + * @return media-type keyed response content metadata + */ + public Map responseContentFor(final int statusCode) { + return new LinkedHashMap<>( + customResponseContent.getOrDefault(statusCode, new LinkedHashMap<>())); + } + /** * Reports whether a status has response payload metadata. * @@ -647,6 +748,34 @@ public Collection returnPayloadStatusCodes() { return new ArrayList<>(returnPayload.keySet()); } + private ResponseContentDefinition responseContentFor( + final int statusCode, final String mediaType) { + customResponseContent.putIfAbsent(statusCode, new LinkedHashMap<>()); + final Map contentByMediaType = + customResponseContent.get(statusCode); + final String normalizedMediaType = mediaType.trim(); + contentByMediaType.putIfAbsent(normalizedMediaType, new ResponseContentDefinition()); + return contentByMediaType.get(normalizedMediaType); + } + + private Map> copyCustomResponseContent() { + final Map> copy = new LinkedHashMap<>(); + for (Map.Entry> statusEntry : + customResponseContent.entrySet()) { + final Map contentByMediaType = new LinkedHashMap<>(); + for (Map.Entry contentEntry : + statusEntry.getValue().entrySet()) { + contentByMediaType.put(contentEntry.getKey(), contentEntry.getValue().copy()); + } + copy.put(statusEntry.getKey(), contentByMediaType); + } + return copy; + } + + private boolean isBlank(final String value) { + return value == null || value.trim().isEmpty(); + } + /** * Adds a request path parameter using the field name. * @@ -707,6 +836,87 @@ public List getRequestUrlParameters() { return new ArrayList<>(requestUrlParams); } + /** + * Adds a request query parameter using the field name. + * + * @param aField field metadata for the query parameter + * @return this definition so route metadata can be chained + */ + public RoutingDefinition addRequestQueryParam(Field aField) { + if (aField == null) { + return this; + } + requestQueryParams.add(new RequestQueryParameter(aField.getName(), aField)); + return this; + } + + /** + * Adds a request query parameter with an explicit parameter name. + * + * @param parameterName query parameter name + * @param field field metadata for the query parameter + * @return this definition so route metadata can be chained + */ + public RoutingDefinition addRequestQueryParam(final String parameterName, final Field field) { + if (parameterName == null || field == null) { + return this; + } + requestQueryParams.add(new RequestQueryParameter(parameterName, field)); + return this; + } + + /** + * Reports whether the route defines request query parameters. + * + * @return true when query parameter metadata is present + */ + public Boolean hasRequestQueryParams() { + return !requestQueryParams.isEmpty(); + } + + /** + * Returns field metadata for request query parameters. + * + * @return list of query parameter fields + */ + public List getRequestQueryParams() { + List fields = new ArrayList<>(); + for (RequestQueryParameter parameter : requestQueryParams) { + fields.add(parameter.field()); + } + return fields; + } + + /** + * Returns full request query parameter metadata. + * + * @return query parameter metadata + */ + public List getRequestQueryParameters() { + return new ArrayList<>(requestQueryParams); + } + + /** + * Marks one response status as intentionally bodyless for documentation. + * + * @param statusCode response status code + * @return this definition so route metadata can be chained + */ + public RoutingDefinition suppressResponseBodyFor(final int statusCode) { + responseStatusesWithSuppressedBodies.add(statusCode); + return this; + } + + /** + * Reports whether one response status should be documented without content. + * + * @param statusCode response status code + * @return true when response body content should be omitted + */ + public boolean hasSuppressedResponseBodyFor(final int statusCode) { + return responseStatusesWithSuppressedBodies.contains(statusCode); + } + /** * Adds custom request header documentation. * @@ -1045,4 +1255,72 @@ public Field field() { return field; } } + + public static final class RequestQueryParameter { + + private final String name; + private final Field field; + + /** + * Creates request query parameter metadata. + * + * @param name query parameter name + * @param field field metadata for documentation + */ + public RequestQueryParameter(final String name, final Field field) { + this.name = name; + this.field = field; + } + + /** + * Returns the query parameter name. + * + * @return query parameter name + */ + public String name() { + return name; + } + + /** + * Returns the field metadata for the query parameter. + * + * @return field metadata + */ + public Field field() { + return field; + } + } + + public static final class ResponseContentDefinition { + + private Schema schema; + private Object example; + + private ResponseContentDefinition() {} + + /** + * Returns the OpenAPI schema configured for the response content. + * + * @return response content schema, or null when only an example is configured + */ + public Schema schema() { + return schema; + } + + /** + * Returns the response example configured for the response content. + * + * @return response example, or null when absent + */ + public Object example() { + return example; + } + + private ResponseContentDefinition copy() { + final ResponseContentDefinition copy = new ResponseContentDefinition(); + copy.schema = schema; + copy.example = example; + return copy; + } + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java index 424e9f53..f7fe58c7 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java @@ -127,12 +127,16 @@ private void returnedEntityWriteRoute( route.replaceStatus(RoutingStatus.returnedFromCall()); if (createRoute) { ensureStatus(route, 201); + ensureStatus(route, 400); + ensureStatus(route, 415); route.returnPayload(201, entityName); route.requestPayload("create_" + entityName); return; } ensureStatus(route, 200); + ensureStatus(route, 400); ensureStatus(route, 404); + ensureStatus(route, 415); ensureStatus(route, 422); ensureStatus(route, 409); route.returnPayload(200, entityName); @@ -151,6 +155,7 @@ private void returnedEntityPutRoute( route.replaceStatus(RoutingStatus.returnedFromCall()); route.clearPossibleStatuses(); route.clearReturnPayloads(); + ensureStatus(route, 400); if (operations.contains(EntityWriteOperation.CREATE)) { ensureStatus(route, 201); route.returnPayload(201, entityName); @@ -160,6 +165,7 @@ private void returnedEntityPutRoute( ensureStatus(route, 404); route.returnPayload(200, entityName); } + ensureStatus(route, 415); ensureStatus(route, 422); ensureStatus(route, 409); route.requestPayload(entityName); @@ -176,6 +182,7 @@ private void applyRelationshipPolicy( || operations.contains(RelationshipWriteOperation.CONNECT_EXISTING) || operations.contains(RelationshipWriteOperation.UPDATE_CONNECTED)) { route.replaceStatus(RoutingStatus.returnedFromCall()); + ensureStatus(route, 415); } else { methodNotAllowed(route); } 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 575b327c..af65ed95 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 @@ -16,6 +16,7 @@ import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.response.RouteApiResponsePolicy; +import uk.co.compendiumdev.thingifier.api.response.RouteApiResponsePolicy.BodyAction; import uk.co.compendiumdev.thingifier.api.security.SecuritySchemeNames; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizer; import uk.co.compendiumdev.thingifier.api.validation.ApiOperationValidator; @@ -1377,31 +1378,48 @@ void applyTo(final RoutingDefinition route) { } private void applyResponsePolicyMetadataTo(final RoutingDefinition route) { - successResponsePolicy().ifPresent(policy -> applyPolicyMetadata(route, policy)); + successResponsePolicy() + .ifPresent(policy -> applyPolicyMetadata(route, policy, policy.statusCode())); validationErrorResponsePolicy() .ifPresent( policy -> { route.addPossibleStatus(RoutingStatus.returnValue(422)); - applyPolicyMetadata(route, policy); + applyPolicyMetadata( + route, + policy, + policy.statusCode() == null ? 422 : policy.statusCode()); }); for (Map.Entry entry : errorResponsePolicies.entrySet()) { route.addPossibleStatus(RoutingStatus.returnValue(entry.getKey())); - applyPolicyMetadata(route, entry.getValue()); + applyPolicyMetadata( + route, + entry.getValue(), + entry.getValue().statusCode() == null + ? entry.getKey() + : entry.getValue().statusCode()); } for (Map.Entry> entry : conditionalErrorResponsePolicies.entrySet()) { route.addPossibleStatus(RoutingStatus.returnValue(entry.getKey())); for (RouteApiResponsePolicy policy : entry.getValue()) { - applyPolicyMetadata(route, policy); + applyPolicyMetadata( + route, + policy, + policy.statusCode() == null ? entry.getKey() : policy.statusCode()); } } } private void applyPolicyMetadata( - final RoutingDefinition route, final RouteApiResponsePolicy policy) { + final RoutingDefinition route, + final RouteApiResponsePolicy policy, + final Integer defaultStatusCode) { if (policy.statusCode() != null) { route.addPossibleStatus(RoutingStatus.returnValue(policy.statusCode())); } + if (policy.bodyAction() == BodyAction.SUPPRESS && defaultStatusCode != null) { + route.suppressResponseBodyFor(defaultStatusCode); + } for (RouteApiResponsePolicy.HeaderValue header : policy.staticHeaders()) { route.addResponseHeader(header.name(), header.value()); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java index 80c5838b..0d5a09c4 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java @@ -1216,7 +1216,7 @@ private void addFixedOptionsRouteDefinitionsTo( fixedOptionsRouteDocumentation(routeUrl), RoutingVerb.OPTIONS, routeUrl, - RoutingStatus.returnValue(204, "the fixed endpoint verb options"), + RoutingStatus.returnValue(200, "the fixed endpoint verb options"), new ResponseHeader("Allow", "OPTIONS")); } } @@ -1286,6 +1286,7 @@ private void applyFixedRoutePayloads( .returnPayload(200, entity.getName()) .requestPayload(entity.getName()) .addPossibleStatus(RoutingStatus.returnValue(404)) + .addPossibleStatus(RoutingStatus.returnValue(415)) .addPossibleStatus(RoutingStatus.returnValue(422)) .addPossibleStatus(RoutingStatus.returnValue(409)); break; @@ -1296,6 +1297,7 @@ private void applyFixedRoutePayloads( .returnPayload(201, entity.getName()) .requestPayload(entity.getName()) .addPossibleStatus(RoutingStatus.returnValue(404)) + .addPossibleStatus(RoutingStatus.returnValue(415)) .addPossibleStatus(RoutingStatus.returnValue(422)) .addPossibleStatus(RoutingStatus.returnValue(409)); break; diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java index 0a96367e..03275451 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java @@ -712,13 +712,34 @@ private String openApiVersionLinks() { } private String openApiVersionLink(final String version, final String specUrl) { - return "
  • OpenAPI v %s JSON %s %s - %s %s
  • %n" + return """ +
  • OpenAPI v %s JSON +
      +
    • %s (%s)
    • +
    • %s (%s)
    • +
    • %s (%s)
    • +
    • %s (%s)
    • +
    • %s (%s)
    • +
    +
  • + """ .formatted( version, - href("[standard validation]", specUrl), - href("[download]", specUrl + "?download"), - href("[less validation]", specUrl + "?permissive"), - href("[download]", specUrl + "?permissive&download")); + href("standard validation", specUrl), + href("download JSON file", specUrl + "?download"), + href("strong schemas", specUrl + "?strongschema=true"), + href("download JSON file", specUrl + "?strongschema=true&download"), + href("operation parameters", specUrl + "?pathparams=operation"), + href("download JSON file", specUrl + "?pathparams=operation&download"), + href( + "strong schemas + operation parameters", + specUrl + "?strongschema=true&pathparams=operation"), + href( + "download JSON file", + specUrl + + "?strongschema=true&pathparams=operation&download"), + href("less validation", specUrl + "?permissive"), + href("download JSON file", specUrl + "?permissive&download")); } private String resolveDocsTitle(final ThingifierApiDocumentationDefn apiDocDefn) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java index 4a8d45e6..6f5156ea 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java @@ -17,6 +17,7 @@ import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; @@ -196,8 +197,10 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { new ApiResponse() .description(subroute.status().description()); addRouteResponseHeaders(subroute, response); + addExplicitResponseContentIfPresent( + response, subroute, subroute.status().value()); addThingifierErrorContentIfNeeded( - components, response, subroute.status().value()); + components, response, subroute, subroute.status().value()); operation.setResponses( new ApiResponses() .addApiResponse( @@ -214,26 +217,37 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { new ApiResponse() .description(possibleStatus.description()); addRouteResponseHeaders(subroute, response); - if (subroute.hasReturnPayloadFor(possibleStatus.value())) { - // assume that all payloads are setup as components - if (routingDefinitions.hasObjectSchemaNamed( - subroute.getReturnPayloadFor( - possibleStatus.value()))) { - String payloadName = + if (!shouldOmitResponseContent( + subroute, possibleStatus.value())) { + addExplicitResponseContentIfPresent( + response, subroute, possibleStatus.value()); + if (response.getContent() != null) { + // Explicit route content takes precedence. + } else if (subroute.hasReturnPayloadFor( + possibleStatus.value())) { + // assume that all payloads are setup as components + if (routingDefinitions.hasObjectSchemaNamed( subroute.getReturnPayloadFor( - possibleStatus.value()); - String ref = "#/components/schemas/" + payloadName; - - response.setContent( - responseContentWith( - ref, - xmlResponseSchemaFor( - routingDefinitions, - payloadName))); + possibleStatus.value()))) { + String payloadName = + subroute.getReturnPayloadFor( + possibleStatus.value()); + String ref = "#/components/schemas/" + payloadName; + + response.setContent( + responseContentWith( + ref, + xmlResponseSchemaFor( + routingDefinitions, + payloadName))); + } + } else { + addThingifierErrorContentIfNeeded( + components, + response, + subroute, + possibleStatus.value()); } - } else { - addThingifierErrorContentIfNeeded( - components, response, possibleStatus.value()); } responses.addApiResponse( @@ -289,28 +303,13 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { for (RoutingDefinition.RequestUrlParameter urlParameter : subroute.getRequestUrlParameters()) { Field aField = urlParameter.field(); - Parameter param = new Parameter(); - param.in("path") - .name(urlParameter.name()) - .required(true) - .example( - openApiExampleValueFor( - aField, - aField.getRandomExampleValue())); - if (aField.hasDescription()) { - param.setDescription(aField.getDescription()); - } - - // if it is in path it will always be required - // but we can remove the type validation - if (!config.includeFieldValidation) { - param.setAllowEmptyValue(true); - } - - param.setSchema( - config.includeFieldValidation - ? schemaForField(aField) - : new Schema<>()); + Parameter param = + fieldParameter( + "path", + urlParameter.name(), + aField, + true, + config.includeFieldValidation); urlParameters.add(param); } @@ -323,6 +322,19 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { } } + if (subroute.hasRequestQueryParams()) { + for (RoutingDefinition.RequestQueryParameter queryParameter : + subroute.getRequestQueryParameters()) { + operationParameters.add( + fieldParameter( + "query", + queryParameter.name(), + queryParameter.field(), + false, + config.includeFieldValidation)); + } + } + addRouteCustomHeaders(subroute, operationParameters); if (!operationParameters.isEmpty()) { @@ -420,6 +432,35 @@ private void addUrlParametersAtEndpointLevel(PathItem path, List urlP } } + private Parameter fieldParameter( + final String in, + final String name, + final Field field, + final boolean required, + final boolean includeFieldValidation) { + Parameter param = new Parameter(); + param.in(in) + .name(name) + .required(required) + .example(openApiExampleValueFor(field, field.getRandomExampleValue())); + if (field.hasDescription()) { + param.setDescription(field.getDescription()); + } + + if (!includeFieldValidation) { + param.setAllowEmptyValue(true); + } + + param.setSchema(includeFieldValidation ? schemaForField(field) : new Schema<>()); + return param; + } + + private boolean shouldOmitResponseContent( + final RoutingDefinition subroute, final int statusCode) { + return subroute.verb() == RoutingVerb.HEAD + || subroute.hasSuppressedResponseBodyFor(statusCode); + } + private boolean shouldDocumentSortParameter( final Thingifier thingifier, final RoutingDefinition route) { if (!route.isFilterable()) { @@ -612,7 +653,7 @@ private void addDefaultAuthErrorResponseIfNeeded( } final ApiResponse response = new ApiResponse().description("Unauthorized"); - addThingifierErrorContentIfNeeded(components, response, 401); + addThingifierErrorContentIfNeeded(components, response, subroute, 401); operation.getResponses().addApiResponse("401", response); } @@ -708,9 +749,38 @@ private Content responseContentWith(final String jsonRef, final Schema xmlSch return content; } + private void addExplicitResponseContentIfPresent( + final ApiResponse response, final RoutingDefinition subroute, final int statusCode) { + if (shouldOmitResponseContent(subroute, statusCode) + || !subroute.hasResponseContentFor(statusCode)) { + return; + } + + Content content = new Content(); + for (Map.Entry contentEntry : + subroute.responseContentFor(statusCode).entrySet()) { + final RoutingDefinition.ResponseContentDefinition contentDefinition = + contentEntry.getValue(); + final MediaType mediaType = new MediaType(); + if (contentDefinition.schema() != null) { + mediaType.setSchema(contentDefinition.schema()); + } + if (contentDefinition.example() != null) { + mediaType.setExample(contentDefinition.example()); + } + content.addMediaType(contentEntry.getKey(), mediaType); + } + response.setContent(content); + } + private void addThingifierErrorContentIfNeeded( - final Components components, final ApiResponse response, final int statusCode) { - if (!isThingifierErrorStatus(statusCode) || response.getContent() != null) { + final Components components, + final ApiResponse response, + final RoutingDefinition subroute, + final int statusCode) { + if (shouldOmitResponseContent(subroute, statusCode) + || !isThingifierErrorStatus(statusCode) + || response.getContent() != null) { return; } ensureThingifierErrorSchema(components); @@ -735,7 +805,7 @@ private void ensureThingifierErrorSchema(final Components components) { final ArraySchema messages = new ArraySchema(); messages.setItems(new StringSchema()); - errorSchema.addProperties("errorMessages", messages); + errorSchema.addProperty("errorMessages", messages); errorSchema.addRequiredItem("errorMessages"); components.addSchemas(THINGIFIER_ERROR_SCHEMA_NAME, errorSchema); @@ -797,7 +867,7 @@ private ObjectSchema asJsonCollectionObjectSchema(EntityDefinition objectSchemaD ArraySchema arrayObject = new ArraySchema(); arrayObject.setItems(asRequiredResponseObjectSchema(objectSchemaDefinition)); - collectionObject.addProperties(objectSchemaDefinition.getPlural(), arrayObject); + collectionObject.addProperty(objectSchemaDefinition.getPlural(), arrayObject); collectionObject.addRequiredItem(objectSchemaDefinition.getPlural()); return collectionObject; @@ -920,7 +990,7 @@ private static ObjectSchema asObjectSchema( propertyItem.setDescription(joinStrings(description, ".")); - object.addProperties(propertyName, propertyItem); + object.addProperty(propertyName, propertyItem); } } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java index 411033a0..22fde343 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java @@ -237,6 +237,32 @@ void notFoundWithBodyKeepsContentTypeHeader() throws Exception { }); } + @Test + void headResponseCanAdvertiseWouldBeContentLengthWithoutWritingBody() throws Exception { + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.HEAD, + "/head-not-found", + (request, response) -> { + response.type("application/json"); + response.status(404); + response.body(""); + response.header("Content-Length", "62"); + return ""; + }), + port -> { + String response = rawHttp("HEAD", "/head-not-found", port); + String responseLowerCase = response.toLowerCase(); + + Assertions.assertTrue(response.startsWith("HTTP/1.1 404 Not Found"), response); + Assertions.assertTrue( + responseLowerCase.contains("content-length: 62"), response); + Assertions.assertFalse(response.contains("500 Server Error"), response); + Assertions.assertTrue(response.endsWith("\r\n\r\n"), response); + }); + } + private HttpResponse get(final String url) throws Exception { return HttpClient.newHttpClient() .send( diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java index c4ec996e..2b60c347 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java @@ -85,7 +85,7 @@ void optionsAdvertisesPatchFormatsForPatchableEntityInstances() throws Exception "", null); - Assertions.assertEquals(204, options.statusCode()); + Assertions.assertEquals(200, options.statusCode()); Assertions.assertEquals( "OPTIONS, GET, HEAD, POST, PUT, PATCH, DELETE", options.headers().firstValue("Allow").orElse("")); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java index 7f4f2331..8b808a73 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java @@ -21,7 +21,7 @@ public void entityCollectionPostDocumentsValidationAndConflictStatuses() { RoutingDefinition route = route(definition, RoutingVerb.POST, "todos"); - Assertions.assertTrue(statuses(route).containsAll(Set.of(201, 400, 422, 409))); + Assertions.assertTrue(statuses(route).containsAll(Set.of(201, 400, 415, 422, 409))); } @Test @@ -37,6 +37,7 @@ public void entityCollectionQueryDocumentsQueryStatusesAndDiscovery() { Assertions.assertTrue( query.getDocumentation().contains("application/vnd.thingifier.query+json")); Assertions.assertEquals("OPTIONS, GET, HEAD, POST, QUERY", options.headerValue()); + Assertions.assertEquals(200, options.status().value()); } @Test @@ -46,10 +47,10 @@ public void entityInstanceWritesDocumentValidationAndConflictStatuses() { Assertions.assertTrue( statuses(route(definition, RoutingVerb.POST, "todos/:id")) - .containsAll(Set.of(200, 404, 422, 409))); + .containsAll(Set.of(200, 400, 404, 415, 422, 409))); Assertions.assertTrue( statuses(route(definition, RoutingVerb.PUT, "todos/:id")) - .containsAll(Set.of(200, 404, 422, 409))); + .containsAll(Set.of(200, 400, 404, 415, 422, 409))); Assertions.assertTrue( statuses(route(definition, RoutingVerb.DELETE, "todos/:id")) .containsAll(Set.of(204, 404))); 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 7fe135b0..85960465 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 @@ -440,6 +440,20 @@ void responsePolicyHeadersAreAddedToGeneratedRouteDocumentation() { route.getResponseHeaderValue("X-Secret-Token")); } + @Test + void suppressedResponsePolicyBodiesAreAddedToGeneratedRouteDocumentation() { + final Thingifier thingifier = secretModel(); + getSecretTokenRoute(thingifier).onError(401).suppressBody(); + + final RoutingDefinition route = + route( + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""), + RoutingVerb.GET, + "secret/token"); + + Assertions.assertTrue(route.hasSuppressedResponseBodyFor(401)); + } + private ThingifierApiRouteRule getSecretNoteRoute(final Thingifier thingifier) { return thingifier .apiSpec() diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java index 75f6237e..d0e84c22 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java @@ -659,7 +659,7 @@ public void generatedDocsReflectConfiguredEntityPolicy() { Assertions.assertTrue( route(definition, RoutingVerb.PUT, "notes/:id").status().isReturnedFromCall()); Assertions.assertEquals( - Set.of(200, 404, 422, 409), + Set.of(200, 400, 404, 422, 409, 415), statusCodes(route(definition, RoutingVerb.PUT, "notes/:id"))); Assertions.assertEquals( "OPTIONS, GET, HEAD, POST, QUERY", @@ -724,14 +724,14 @@ public void generatedDocsReflectPutCreateCapabilities() { unsupported.apiConfig().writeMethods().entities().putCan(); Assertions.assertEquals( - Set.of(201, 422, 409), + Set.of(201, 400, 422, 409, 415), statusCodes( route( new ApiRoutingDefinitionDocGenerator(createOnly).generate(""), RoutingVerb.PUT, "notes/:id"))); Assertions.assertEquals( - Set.of(201, 200, 404, 422, 409), + Set.of(201, 200, 400, 404, 422, 409, 415), statusCodes( route( new ApiRoutingDefinitionDocGenerator(createAndUpdate).generate(""), @@ -760,7 +760,7 @@ public void generatedDocsReflectPutIdentifierLocationPolicy() { .status() .isReturnedFromCall()); Assertions.assertEquals( - Set.of(201, 200, 404, 422, 409), + Set.of(201, 200, 400, 404, 422, 409, 415), statusCodes(route(collectionPutDefinition, RoutingVerb.PUT, "notes"))); Assertions.assertEquals( "OPTIONS, GET, HEAD, POST, QUERY, PUT", 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 52d44683..e627172b 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 @@ -99,6 +99,7 @@ void fixedGetRouteGeneratesOptionsAllowHeader() { Assertions.assertEquals("Allow", route.header()); Assertions.assertEquals("OPTIONS, GET, HEAD", route.headerValue()); + Assertions.assertEquals(200, route.status().value()); } @Test @@ -204,6 +205,30 @@ void openApiDocumentsSingleInstanceSchemaForShapedFixedRoute() { Assertions.assertEquals("#/components/schemas/SecretNoteResponse", schemaRef); } + @Test + void fixedWriteRouteDocumentsUnsupportedContentTypeError() { + final Thingifier thingifier = secretModel(); + postSecretNoteRoute(thingifier); + 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") + .getPost() + .getResponses() + .get("415") + .getContent() + .get("application/json") + .getSchema() + .get$ref(); + + Assertions.assertEquals( + "#/components/schemas/" + Swaggerizer.THINGIFIER_ERROR_SCHEMA_NAME, schemaRef); + } + @Test void getFixedRouteReturnsConfiguredInstance() { final Thingifier thingifier = secretModel(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java index aaa9ebcf..098f3073 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java @@ -39,8 +39,25 @@ void apiDocumentationShowsApiUiLinksByDefault() { Assertions.assertTrue(docs.contains("
  • OpenAPI v 3.1 JSON")); Assertions.assertTrue(docs.contains("
  • OpenAPI v 3.2 JSON")); Assertions.assertTrue( - docs.contains("href='/mirror/docs/openapi-3.2.json'>[standard validation]")); + docs.contains("href='/mirror/docs/openapi-3.2.json'>standard validation")); + Assertions.assertTrue(docs.contains(">download JSON file")); Assertions.assertTrue(docs.contains("href='/mirror/docs/openapi-3.2.json?download'")); + Assertions.assertTrue( + docs.contains("href='/mirror/docs/openapi-3.2.json?strongschema=true'")); + Assertions.assertTrue( + docs.contains( + "href='/mirror/docs/openapi-3.2.json?strongschema=true&download'")); + Assertions.assertTrue( + docs.contains("href='/mirror/docs/openapi-3.2.json?pathparams=operation'")); + Assertions.assertTrue( + docs.contains( + "href='/mirror/docs/openapi-3.2.json?pathparams=operation&download'")); + Assertions.assertTrue( + docs.contains( + "href='/mirror/docs/openapi-3.2.json?strongschema=true&pathparams=operation'")); + Assertions.assertTrue( + docs.contains( + "href='/mirror/docs/openapi-3.2.json?strongschema=true&pathparams=operation&download'")); Assertions.assertTrue(docs.contains("href='/mirror/docs/openapi-3.2.json?permissive'")); Assertions.assertTrue( docs.contains("href='/mirror/docs/openapi-3.2.json?permissive&download'")); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerResponseContentTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerResponseContentTest.java new file mode 100644 index 00000000..47deee82 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerResponseContentTest.java @@ -0,0 +1,184 @@ +package uk.co.compendiumdev.thingifier.swaggerizer; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; + +class SwaggerizerResponseContentTest { + + @Test + void openApi30OmitsContentForSuppressedErrorResponseBodies() { + final Thingifier thingifier = secretModel(); + secretNoteRoute(thingifier).onError(401).suppressBody(); + + final JsonObject document = + JsonParser.parseString( + new Swaggerizer(apiDefn(thingifier)) + .asJson(OpenApiSpecificationVersion.OPENAPI_3_0)) + .getAsJsonObject(); + final JsonObject response = + document.getAsJsonObject("paths") + .getAsJsonObject("/secret/note") + .getAsJsonObject("get") + .getAsJsonObject("responses") + .getAsJsonObject("401"); + + Assertions.assertFalse(response.has("content")); + } + + @Test + void headOperationsDoNotDocumentResponseBodies() { + final Thingifier thingifier = secretModel(); + thingifier.apiSpec().security().apiKey("authToken", "X-AUTH-TOKEN"); + secretNoteRoute(thingifier).secureWithApiKey("authToken").onError(401).suppressBody(); + + final OpenAPI openApi = new Swaggerizer(apiDefn(thingifier)).swagger(); + final Operation head = openApi.getPaths().get("/secret/note").getHead(); + + Assertions.assertTrue(head.getResponses().containsKey("200")); + Assertions.assertTrue(head.getResponses().containsKey("401")); + for (ApiResponse response : head.getResponses().values()) { + Assertions.assertNull(response.getContent()); + } + } + + @Test + void customRouteQueryParamsAreDocumentedAsQueryParameters() { + final OpenAPI openApi = new Swaggerizer(apiDefnWithExportRoute()).swagger(); + final Operation get = openApi.getPaths().get("/todos/export").getGet(); + final Parameter format = get.getParameters().get(0); + + Assertions.assertEquals("format", format.getName()); + Assertions.assertEquals("query", format.getIn()); + Assertions.assertFalse(Boolean.TRUE.equals(format.getRequired())); + Assertions.assertEquals("string", format.getSchema().getType()); + Assertions.assertEquals("csv", format.getExample()); + } + + @Test + void customRouteResponseContentIsDocumentedByStatusAndMediaType() { + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + final ObjectSchema statusSchema = new ObjectSchema(); + statusSchema.addProperty("status", new StringSchema()); + apiDefn.addRouteToDocumentation( + new RoutingDefinition( + RoutingVerb.GET, "/status", RoutingStatus.returnedFromCall(), null) + .addDocumentation("status") + .addPossibleStatuses(200, 400) + .responseSchema(200, "application/json", statusSchema) + .responseExample(200, "application/json", Map.of("status", "ok")) + .responseStringSchema(200, "text/plain") + .responseSchemaRef( + 400, "application/json", "#/components/schemas/Problem")); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + final Operation get = openApi.getPaths().get("/status").getGet(); + + Assertions.assertTrue( + get.getResponses() + .get("200") + .getContent() + .get("application/json") + .getSchema() + .getProperties() + .containsKey("status")); + Assertions.assertEquals( + Map.of("status", "ok"), + get.getResponses().get("200").getContent().get("application/json").getExample()); + Assertions.assertEquals( + "string", + get.getResponses().get("200").getContent().get("text/plain").getSchema().getType()); + Assertions.assertEquals( + "#/components/schemas/Problem", + get.getResponses() + .get("400") + .getContent() + .get("application/json") + .getSchema() + .get$ref()); + } + + @Test + void openApi30StrongSchemaSerializesCustomRouteQueryParamTypes() { + final JsonObject document = + JsonParser.parseString( + new Swaggerizer(apiDefnWithExportRoute()) + .asJson( + strongConfig( + OpenApiSpecificationVersion.OPENAPI_3_0))) + .getAsJsonObject(); + final JsonObject parameter = + document.getAsJsonObject("paths") + .getAsJsonObject("/todos/export") + .getAsJsonObject("get") + .getAsJsonArray("parameters") + .get(0) + .getAsJsonObject(); + + Assertions.assertEquals("query", parameter.get("in").getAsString()); + Assertions.assertEquals("format", parameter.get("name").getAsString()); + Assertions.assertEquals( + "string", parameter.getAsJsonObject("schema").get("type").getAsString()); + } + + private ThingifierApiDocumentationDefn apiDefnWithExportRoute() { + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.addRouteToDocumentation( + new RoutingDefinition( + RoutingVerb.GET, + "/todos/export", + RoutingStatus.returnedFromCall(), + null) + .addDocumentation("export todos") + .addPossibleStatuses(200) + .addRequestQueryParam( + Field.is("format", FieldType.STRING) + .withExample("csv") + .withDescription("Export format"))); + return apiDefn; + } + + private SwaggerGenerationConfig strongConfig(final OpenApiSpecificationVersion version) { + final SwaggerGenerationConfig config = new SwaggerGenerationConfig(); + config.openApiSpecificationVersion = version; + config.strongSchemas = true; + return config; + } + + private ThingifierApiDocumentationDefn apiDefn(final Thingifier thingifier) { + return new ThingifierApiDocumentationDefn().setThingifier(thingifier); + } + + private ThingifierApiRouteRule secretNoteRoute(final Thingifier thingifier) { + return thingifier + .apiSpec() + .route(RoutingVerb.GET, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note"); + } + + private Thingifier secretModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition note = thingifier.defineThing("secretnote", "secretnotes"); + note.addAsPrimaryKeyField(Field.is("id", FieldType.STRING).withExample("note")); + note.addField(Field.is("text", FieldType.STRING).withExample("visible text")); + return thingifier; + } +}