From 3f72bd4eed4ea54300eddf0a8852d64d1584c787 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 16 Sep 2026 10:10:27 +0100 Subject: [PATCH] Add strong OpenAPI schema tooling --- .../ThingifierAutoDocGenRouting.java | 51 ++- .../ThingifierApiScopedSessionDefinition.java | 14 + .../api/spec/ThingifierApiSpec.java | 6 + .../swaggerizer/SwaggerGenerationConfig.java | 23 ++ .../thingifier/swaggerizer/Swaggerizer.java | 313 +++++++++++++----- .../ThingifierApiScopedSessionPolicyTest.java | 41 ++- .../swaggerizer/SwaggerizerSecurityTest.java | 28 ++ .../SwaggerizerStrongSchemaTest.java | 160 +++++++++ 8 files changed, 536 insertions(+), 100 deletions(-) create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerStrongSchemaTest.java 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 6cf17769..f5d858d5 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 @@ -10,6 +10,7 @@ import uk.co.compendiumdev.thingifier.htmlgui.htmlgen.RestApiDocumentationGenerator; import uk.co.compendiumdev.thingifier.swaggerizer.OpenApiSpecificationVersion; import uk.co.compendiumdev.thingifier.swaggerizer.ScalarUiPage; +import uk.co.compendiumdev.thingifier.swaggerizer.SwaggerGenerationConfig; import uk.co.compendiumdev.thingifier.swaggerizer.SwaggerUiPage; import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; @@ -107,7 +108,9 @@ public ThingifierAutoDocGenRouting( get( swaggerDownloadPath, (request, response) -> { - String permissive = request.queryParam("permissive"); + final SwaggerGenerationConfig config = + swaggerGenerationConfig( + OpenApiSpecificationVersion.OPENAPI_3_1, request); response.type("text/html"); response.status(200); @@ -119,7 +122,7 @@ public ThingifierAutoDocGenRouting( System.out.println( "Possibly incomplete swagger generation, api not defined from model"); } - if (permissive != null) { + if (config.includeMethodNotAllowedEndpoints && !config.includeFieldValidation) { nameprefix = nameprefix + "permissive-"; } response.header("Content-Type", "application/octet-stream"); @@ -130,8 +133,7 @@ public ThingifierAutoDocGenRouting( // TODO: the swaggerizer could be stored at a class level and allow caching to // be used for the output return new Swaggerizer(apiDefn) - .asJsonWithPreferredServer( - permissive != null, HttpRequestOrigin.from(request)); + .asJsonWithPreferredServer(config, HttpRequestOrigin.from(request)); }); } @@ -144,19 +146,52 @@ private void registerOpenApiEndpoint( (request, response) -> { response.type("application/json"); response.status(200); - final boolean permissive = request.queryParam("permissive") != null; + final SwaggerGenerationConfig config = + swaggerGenerationConfig(version, request); if (request.queryParam("download") != null) { response.header( "Content-Disposition", "attachment; filename=\"%s\"" - .formatted(openApiDownloadFilename(path, permissive))); + .formatted( + openApiDownloadFilename( + path, + config.includeMethodNotAllowedEndpoints + && !config.includeFieldValidation))); } return new Swaggerizer(apiDefn) - .asJsonWithPreferredServer( - version, permissive, HttpRequestOrigin.from(request)); + .asJsonWithPreferredServer(config, HttpRequestOrigin.from(request)); }); } + private SwaggerGenerationConfig swaggerGenerationConfig( + final OpenApiSpecificationVersion version, final HttpServerRequest request) { + final boolean permissive = request.queryParam("permissive") != null; + final boolean strongSchemas = truthyQueryParam(request.queryParam("strongschema")); + + final SwaggerGenerationConfig config = new SwaggerGenerationConfig(); + config.openApiSpecificationVersion = version; + config.includeMethodNotAllowedEndpoints = permissive; + config.includeFieldValidation = !permissive || strongSchemas; + config.strongSchemas = strongSchemas; + config.pathParameterPlacement = pathParameterPlacement(request.queryParam("pathparams")); + return config; + } + + private boolean truthyQueryParam(final String value) { + if (value == null) { + return false; + } + return !"false".equalsIgnoreCase(value.trim()) && !"0".equals(value.trim()); + } + + private SwaggerGenerationConfig.PathParameterPlacement pathParameterPlacement( + final String placement) { + if ("operation".equalsIgnoreCase(placement)) { + return SwaggerGenerationConfig.PathParameterPlacement.OPERATION; + } + return SwaggerGenerationConfig.PathParameterPlacement.PATH; + } + private String openApiDownloadFilename(final String path, final boolean permissive) { final String filename = path.substring(path.lastIndexOf("/") + 1); if (permissive) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java index 63430e66..86cd1677 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java @@ -382,6 +382,20 @@ public boolean requiresAuthenticatedScopeForWrites() { return authenticatedScopeForWrites; } + /** + * @return configured missing-credential response status code + */ + public int missingRequiredCredentialStatusCode() { + return missingCredentialStatusCode; + } + + /** + * @return configured invalid-credential response status code + */ + public int invalidCredentialStatusCode() { + return invalidCredentialStatusCode; + } + /** * @return configured missing-credential response */ 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 f49da78b..80c5838b 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 @@ -1104,6 +1104,12 @@ private void applyScopedSessionDocumentationTo( securitySpec.apiKey( definition.name(), definition.credentialSourceName()); route.secureWithApiKey(definition.name()); + route.addPossibleStatus( + RoutingStatus.returnValue( + definition.missingRequiredCredentialStatusCode())); + route.addPossibleStatus( + RoutingStatus.returnValue( + definition.invalidCredentialStatusCode())); }); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerGenerationConfig.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerGenerationConfig.java index fba3d759..84499190 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerGenerationConfig.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerGenerationConfig.java @@ -2,9 +2,32 @@ public class SwaggerGenerationConfig { + public enum PathParameterPlacement { + PATH, + OPERATION + } + // include endpoints with only a 405 status code? public boolean includeMethodNotAllowedEndpoints = false; public boolean includeFieldValidation = true; + public boolean strongSchemas = false; + public PathParameterPlacement pathParameterPlacement = PathParameterPlacement.PATH; public OpenApiSpecificationVersion openApiSpecificationVersion = OpenApiSpecificationVersion.OPENAPI_3_1; + + public SwaggerGenerationConfig copy() { + final SwaggerGenerationConfig copy = new SwaggerGenerationConfig(); + copy.includeMethodNotAllowedEndpoints = includeMethodNotAllowedEndpoints; + copy.includeFieldValidation = includeFieldValidation; + copy.strongSchemas = strongSchemas; + copy.pathParameterPlacement = pathParameterPlacement; + copy.openApiSpecificationVersion = openApiSpecificationVersion; + return copy; + } + + public SwaggerGenerationConfig copyFor(final OpenApiSpecificationVersion version) { + final SwaggerGenerationConfig copy = copy(); + copy.openApiSpecificationVersion = version; + return copy; + } } 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 2ac2b154..4a8d45e6 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 @@ -32,12 +32,21 @@ import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.FloatValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.IntegerValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.MatchesRegexValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.MaximumLengthValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.NotEmptyValidationRule; import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.ValidationRule; import uk.co.compendiumdev.thingifier.core.query.PaginationParams; import uk.co.compendiumdev.thingifier.core.query.SortByFieldName; public class Swaggerizer { + public static final String THINGIFIER_ERROR_SCHEMA_NAME = "ThingifierError"; + private static final String THINGIFIER_ERROR_SCHEMA_REF = + "#/components/schemas/" + THINGIFIER_ERROR_SCHEMA_NAME; + private final ThingifierApiDocumentationDefn apiDefn; OpenAPI apiNormal; OpenAPI apiPermissive; @@ -67,7 +76,7 @@ public OpenAPI swagger(final OpenApiSpecificationVersion version) { config.includeMethodNotAllowedEndpoints = false; config.includeFieldValidation = true; - config.openApiSpecificationVersion = version.swaggerCoreGenerationVersion(); + config.openApiSpecificationVersion = version; return swagger(config); } @@ -84,7 +93,7 @@ public OpenAPI swaggerPermissive(final OpenApiSpecificationVersion version) { config.includeMethodNotAllowedEndpoints = true; config.includeFieldValidation = false; - config.openApiSpecificationVersion = version.swaggerCoreGenerationVersion(); + config.openApiSpecificationVersion = version; return swagger(config); } @@ -132,7 +141,7 @@ public OpenAPI swagger(SwaggerGenerationConfig config) { List processedAdditionalRoutes = new ArrayList<>(); - Components components = convertEntityDefinitionsToComponents(routingDefinitions); + Components components = convertEntityDefinitionsToComponents(routingDefinitions, config); api.components(components); for (String bearerSchemeName : thingifier.apiSpec().security().bearerSchemes()) { @@ -187,6 +196,8 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { new ApiResponse() .description(subroute.status().description()); addRouteResponseHeaders(subroute, response); + addThingifierErrorContentIfNeeded( + components, response, subroute.status().value()); operation.setResponses( new ApiResponses() .addApiResponse( @@ -220,6 +231,9 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { routingDefinitions, payloadName))); } + } else { + addThingifierErrorContentIfNeeded( + components, response, possibleStatus.value()); } responses.addApiResponse( @@ -256,6 +270,7 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { } addRouteSecuritySchemes(subroute, components, operation); + addDefaultAuthErrorResponseIfNeeded(subroute, components, operation); if (shouldDocumentSortParameter(thingifier, subroute)) { operationParameters.add( @@ -292,17 +307,20 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { param.setAllowEmptyValue(true); } - Schema schema = new Schema<>(); - - if (config.includeFieldValidation) { - addParamSchemeValidationFromField(schema, aField); - } - - param.setSchema(schema); + param.setSchema( + config.includeFieldValidation + ? schemaForField(aField) + : new Schema<>()); urlParameters.add(param); } - addUrlParametersAtEndpointLevel(path, urlParameters); + if (config.pathParameterPlacement + == SwaggerGenerationConfig.PathParameterPlacement + .OPERATION) { + operationParameters.addAll(urlParameters); + } else { + addUrlParametersAtEndpointLevel(path, urlParameters); + } } addRouteCustomHeaders(subroute, operationParameters); @@ -344,16 +362,18 @@ private boolean isVisibleRoute( } private Components convertEntityDefinitionsToComponents( - ApiRoutingDefinition routingDefinitions) { + ApiRoutingDefinition routingDefinitions, final SwaggerGenerationConfig config) { Components components = new Components(); for (EntityDefinition objectSchemaDefinition : routingDefinitions.getObjectSchemas()) { // add individual entity schema - ObjectSchema object = asObjectSchema(objectSchemaDefinition); + ObjectSchema object = + asObjectSchema(objectSchemaDefinition, false, config.strongSchemas); components.addSchemas(objectSchemaDefinition.getName(), object); // add create schema with ID removed for auto added ids - ObjectSchema createObject = asCreateObjectSchema(objectSchemaDefinition); + ObjectSchema createObject = + asCreateObjectSchema(objectSchemaDefinition, config.strongSchemas); createObject.title("create " + createObject.getTitle()); components.addSchemas("create_" + objectSchemaDefinition.getName(), createObject); @@ -362,10 +382,11 @@ private Components convertEntityDefinitionsToComponents( components.addSchemas(objectSchemaDefinition.getPlural(), collectionObject); for (EntityViewDefinition view : objectSchemaDefinition.getViews()) { - ObjectSchema viewObject = asResponseViewObjectSchema(view); + ObjectSchema viewObject = asResponseViewObjectSchema(view, config.strongSchemas); components.addSchemas(view.getName(), viewObject); - ObjectSchema createViewObject = asRequestViewObjectSchema(view); + ObjectSchema createViewObject = + asRequestViewObjectSchema(view, config.strongSchemas); createViewObject.title("create " + createViewObject.getTitle()); components.addSchemas("create_" + view.getName(), createViewObject); } @@ -383,32 +404,6 @@ private Schema xmlResponseSchemaFor( return refSchemaFor("#/components/schemas/" + payloadName); } - private void addParamSchemeValidationFromField(Schema schema, Field aField) { - switch (aField.getType()) { - case AUTO_INCREMENT: - case INTEGER: - schema.addType("integer"); - break; - - case FLOAT: - schema.addType("number"); - break; - case BOOLEAN: - schema.addType("boolean"); - break; - case AUTO_GUID: - case STRING: - case DATE: - case ENUM: // TODO: properly do Enums - schema.addType("string"); - break; - default: - schema.addType("string"); - } - - // TODO: add min max etc. - } - private void addUrlParametersAtEndpointLevel(PathItem path, List urlParameters) { for (Parameter param : urlParameters) { boolean exists = false; @@ -551,22 +546,29 @@ private void addRouteCustomHeaders( Parameter param = new Parameter(); param.in("header").name(headerName).required(true); - Schema schema = new Schema<>(); - - switch (headerType) { - case "guid": - break; - default: - schema.addType(headerType); - } - - param.setSchema(schema); + param.setSchema(headerSchemaFor(headerType)); operationParameters.add(param); } } } } + private Schema headerSchemaFor(final String headerType) { + switch (headerType) { + case "guid": + return new StringSchema().format("uuid"); + case "integer": + return new IntegerSchema(); + case "number": + return new NumberSchema(); + case "boolean": + return new BooleanSchema(); + case "string": + default: + return new StringSchema(); + } + } + private void addRouteResponseHeaders( final RoutingDefinition subroute, final ApiResponse response) { if (!subroute.hasResponseHeaders()) { @@ -595,6 +597,32 @@ private void addRouteSecuritySchemes( addLegacyRouteSecuritySchemes(subroute, components, operation); } + private void addDefaultAuthErrorResponseIfNeeded( + final RoutingDefinition subroute, + final Components components, + final Operation operation) { + if (!routeHasSecurity(subroute)) { + return; + } + if (operation.getResponses() == null) { + operation.setResponses(new ApiResponses()); + } + if (operation.getResponses().containsKey("401")) { + return; + } + + final ApiResponse response = new ApiResponse().description("Unauthorized"); + addThingifierErrorContentIfNeeded(components, response, 401); + operation.getResponses().addApiResponse("401", response); + } + + private boolean routeHasSecurity(final RoutingDefinition subroute) { + return subroute.hasAuthSchemeNames() + || subroute.isSecuredByBasicAuth() + || subroute.isSecuredByBearerAuth() + || subroute.isSecuredByApiKeyAuth(); + } + private void addLegacyRouteSecuritySchemes( final RoutingDefinition subroute, final Components components, @@ -680,6 +708,39 @@ private Content responseContentWith(final String jsonRef, final Schema xmlSch return content; } + private void addThingifierErrorContentIfNeeded( + final Components components, final ApiResponse response, final int statusCode) { + if (!isThingifierErrorStatus(statusCode) || response.getContent() != null) { + return; + } + ensureThingifierErrorSchema(components); + response.setContent( + responseContentWith( + THINGIFIER_ERROR_SCHEMA_REF, refSchemaFor(THINGIFIER_ERROR_SCHEMA_REF))); + } + + private boolean isThingifierErrorStatus(final int statusCode) { + return statusCode >= 400 && statusCode < 600; + } + + private void ensureThingifierErrorSchema(final Components components) { + if (components.getSchemas() != null + && components.getSchemas().containsKey(THINGIFIER_ERROR_SCHEMA_NAME)) { + return; + } + + final ObjectSchema errorSchema = new ObjectSchema(); + errorSchema.setTitle(THINGIFIER_ERROR_SCHEMA_NAME); + errorSchema.setDescription("Standard Thingifier error response"); + + final ArraySchema messages = new ArraySchema(); + messages.setItems(new StringSchema()); + errorSchema.addProperties("errorMessages", messages); + errorSchema.addRequiredItem("errorMessages"); + + components.addSchemas(THINGIFIER_ERROR_SCHEMA_NAME, errorSchema); + } + private Schema refSchemaFor(final String ref) { Schema object = new Schema<>(); object.set$ref(ref); @@ -783,32 +844,38 @@ private static boolean isAlwaysRenderedResponseField(final Field field) { } private static ObjectSchema asObjectSchema(EntityDefinition objectSchemaDefinition) { - return asObjectSchema(objectSchemaDefinition, false); + return asObjectSchema(objectSchemaDefinition, false, false); } - private static ObjectSchema asCreateObjectSchema(EntityDefinition objectSchemaDefinition) { - return asObjectSchema(objectSchemaDefinition, true); + private static ObjectSchema asCreateObjectSchema( + EntityDefinition objectSchemaDefinition, final boolean strongSchemas) { + return asObjectSchema(objectSchemaDefinition, true, null, true, strongSchemas); } - private static ObjectSchema asRequestViewObjectSchema(final EntityViewDefinition view) { - return asObjectSchema(view.getEntity(), true, view, true); + private static ObjectSchema asRequestViewObjectSchema( + final EntityViewDefinition view, final boolean strongSchemas) { + return asObjectSchema(view.getEntity(), true, view, true, strongSchemas); } - private static ObjectSchema asResponseViewObjectSchema(final EntityViewDefinition view) { - return asObjectSchema(view.getEntity(), false, view, false); + private static ObjectSchema asResponseViewObjectSchema( + final EntityViewDefinition view, final boolean strongSchemas) { + return asObjectSchema(view.getEntity(), false, view, false, strongSchemas); } // no auto fields in create private static ObjectSchema asObjectSchema( - EntityDefinition objectSchemaDefinition, Boolean skipAutos) { - return asObjectSchema(objectSchemaDefinition, skipAutos, null, false); + EntityDefinition objectSchemaDefinition, + Boolean skipAutos, + final boolean strongSchemas) { + return asObjectSchema(objectSchemaDefinition, skipAutos, null, false, strongSchemas); } private static ObjectSchema asObjectSchema( EntityDefinition objectSchemaDefinition, Boolean skipAutos, EntityViewDefinition view, - Boolean requestSchema) { + Boolean requestSchema, + final boolean strongSchemas) { ObjectSchema object = new ObjectSchema(); object.setDescription(schemaDescriptionFor(objectSchemaDefinition)); object.setTitle(view == null ? objectSchemaDefinition.getName() : view.getName()); @@ -828,7 +895,7 @@ private static ObjectSchema asObjectSchema( && (propertyDefinition.getType() == FieldType.AUTO_GUID || propertyDefinition.getType() == FieldType.AUTO_INCREMENT)) { } else { - Schema propertyItem = new Schema<>(); + Schema propertyItem = schemaForField(propertyDefinition); final List examples = propertyDefinition.getExamples(); if (!examples.isEmpty()) { propertyItem.setExample( @@ -844,27 +911,11 @@ private static ObjectSchema asObjectSchema( description.add(validationRule.getExplanation()); } - switch (propertyDefinition.getType()) { - case AUTO_INCREMENT: - case INTEGER: - propertyItem.addType("integer"); - - break; - - case FLOAT: - propertyItem.addType("number"); - break; - case BOOLEAN: - propertyItem.addType("boolean"); - break; - case AUTO_GUID: - case STRING: - case DATE: - case ENUM: // TODO: properly do Enums - propertyItem.addType("string"); - break; - default: - propertyItem.addType("string"); + if (strongSchemas) { + addOpenApiValidationFromField(propertyItem, propertyDefinition); + if (requestSchema && propertyDefinition.isMandatory()) { + object.addRequiredItem(propertyName); + } } propertyItem.setDescription(joinStrings(description, ".")); @@ -881,6 +932,58 @@ private static ObjectSchema asObjectSchema( return object; } + private static Schema schemaForField(final Field field) { + switch (field.getType()) { + case AUTO_INCREMENT: + case INTEGER: + return new IntegerSchema(); + case FLOAT: + return new NumberSchema(); + case BOOLEAN: + return new BooleanSchema(); + case AUTO_GUID: + return new StringSchema().format("uuid"); + case DATE: + return new StringSchema().format("date"); + case STRING: + case ENUM: + default: + return new StringSchema(); + } + } + + private static void addOpenApiValidationFromField(final Schema schema, final Field field) { + for (ValidationRule rule : field.getAllValidationRules()) { + if (rule instanceof NotEmptyValidationRule) { + schema.setMinLength(1); + } + if (rule instanceof MaximumLengthValidationRule) { + schema.setMaxLength(((MaximumLengthValidationRule) rule).getMaximumLength()); + } + if (rule instanceof MatchesRegexValidationRule) { + schema.setPattern(((MatchesRegexValidationRule) rule).getRegexToMatch()); + } + if (rule instanceof IntegerValidationRule) { + final IntegerValidationRule integerRule = (IntegerValidationRule) rule; + if (integerRule.getMinimumIntegerValue() != null) { + schema.setMinimum(BigDecimal.valueOf(integerRule.getMinimumIntegerValue())); + } + if (integerRule.getMaximumIntegerValue() != null) { + schema.setMaximum(BigDecimal.valueOf(integerRule.getMaximumIntegerValue())); + } + } + if (rule instanceof FloatValidationRule) { + final FloatValidationRule floatRule = (FloatValidationRule) rule; + if (floatRule.getMinimumFloatValue() != null) { + schema.setMinimum(BigDecimal.valueOf(floatRule.getMinimumFloatValue())); + } + if (floatRule.getMaximumFloatValue() != null) { + schema.setMaximum(BigDecimal.valueOf(floatRule.getMaximumFloatValue())); + } + } + } + } + private static Object openApiExampleValueFor(final Field field, final String example) { if (example == null) { return null; @@ -948,16 +1051,22 @@ public String asJsonWithPreferredServer( final OpenApiSpecificationVersion version, final boolean permissive, final String preferredServerUrl) { + final SwaggerGenerationConfig config = configFor(version, permissive); + return asJsonWithPreferredServer(config, preferredServerUrl); + } + + public String asJsonWithPreferredServer( + final SwaggerGenerationConfig config, final String preferredServerUrl) { + final OpenApiSpecificationVersion version = config.openApiSpecificationVersion; if (version.requiresOpenApi32Finalization()) { return new OpenApi32Finalizer() .finalizeJson( asJsonWithPreferredServer( - OpenApiSpecificationVersion.OPENAPI_3_1, - permissive, + config.copyFor(OpenApiSpecificationVersion.OPENAPI_3_1), preferredServerUrl)); } - final OpenAPI api = permissive ? swaggerPermissive(version) : swagger(version); + final OpenAPI api = swagger(config); preferServer(api, preferredServerUrl); return pretty(api, version); } @@ -971,13 +1080,28 @@ public String asJson(final OpenApiSpecificationVersion version) { } public String asJson(final OpenApiSpecificationVersion version, boolean permissive) { + return asJson(configFor(version, permissive)); + } + + public String asJson(final SwaggerGenerationConfig config) { + final OpenApiSpecificationVersion version = config.openApiSpecificationVersion; if (version.requiresOpenApi32Finalization()) { return new OpenApi32Finalizer() - .finalizeJson(asJson(OpenApiSpecificationVersion.OPENAPI_3_1, permissive)); + .finalizeJson(asJson(config.copyFor(OpenApiSpecificationVersion.OPENAPI_3_1))); } if (version != OpenApiSpecificationVersion.OPENAPI_3_1) { - return pretty(permissive ? swaggerPermissive(version) : swagger(version), version); + return pretty(swagger(config), version); + } + + final boolean cacheable = + !config.strongSchemas + && config.pathParameterPlacement + == SwaggerGenerationConfig.PathParameterPlacement.PATH + && (!config.includeMethodNotAllowedEndpoints + || !config.includeFieldValidation); + if (!cacheable) { + return pretty(swagger(config), version); } if (apiNormal == null) { @@ -986,13 +1110,22 @@ public String asJson(final OpenApiSpecificationVersion version, boolean permissi if (apiPermissive == null) { apiPermissive = swaggerPermissive(); } - if (permissive) { + if (config.includeMethodNotAllowedEndpoints && !config.includeFieldValidation) { return pretty(apiPermissive, version); } else { return pretty(apiNormal, version); } } + private SwaggerGenerationConfig configFor( + final OpenApiSpecificationVersion version, final boolean permissive) { + final SwaggerGenerationConfig config = new SwaggerGenerationConfig(); + config.includeMethodNotAllowedEndpoints = permissive; + config.includeFieldValidation = !permissive; + config.openApiSpecificationVersion = version; + return config; + } + private String pretty(final OpenAPI api, final OpenApiSpecificationVersion version) { if (version == OpenApiSpecificationVersion.OPENAPI_3_0) { return Json.pretty(api); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java index ced936fe..a6a1dc04 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java @@ -29,6 +29,7 @@ import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; class ThingifierApiScopedSessionPolicyTest { @@ -858,8 +859,7 @@ void requiredScopedSessionHeaderIsDocumentedAsApiKeySecurity() { final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); apiDefn.setThingifier(thingifier); - final OpenAPI openApi = - new uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer(apiDefn).swagger(); + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); final SecurityScheme scheme = openApi.getComponents().getSecuritySchemes().get("challenger"); @@ -879,6 +879,43 @@ void requiredScopedSessionHeaderIsDocumentedAsApiKeySecurity() { .next()); } + @Test + void requiredScopedSessionDocumentsConfiguredCredentialFailureStatuses() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .onMissingRequiredCredential(401, "Missing scoped session") + .onInvalidCredential(403, "Invalid scoped session") + .requireAuthenticatedScopeForWrites(); + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setThingifier(thingifier); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + + Assertions.assertEquals( + "#/components/schemas/" + Swaggerizer.THINGIFIER_ERROR_SCHEMA_NAME, + openApi.getPaths() + .get("/todos") + .getPost() + .getResponses() + .get("401") + .getContent() + .get("application/json") + .getSchema() + .get$ref()); + Assertions.assertEquals( + "#/components/schemas/" + Swaggerizer.THINGIFIER_ERROR_SCHEMA_NAME, + openApi.getPaths() + .get("/todos") + .getPost() + .getResponses() + .get("403") + .getContent() + .get("application/json") + .getSchema() + .get$ref()); + } + private ThingifierApiScopedSessionDefinition scopedSession(final Thingifier thingifier) { return thingifier.apiSpec().scopedSession("challenger").fromHeader("X-CHALLENGER"); } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSecurityTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSecurityTest.java index 2818f8f5..d27ef7cd 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSecurityTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSecurityTest.java @@ -115,6 +115,34 @@ void apiKeySecuredRoutesAddHeaderApiKeySecuritySchemeAndOperationRequirement() { .next()); } + @Test + void securedRoutesDocumentDefaultUnauthorizedErrorResponse() { + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.addRouteToDocumentation( + new RoutingDefinition( + RoutingVerb.GET, + "/secret/token", + RoutingStatus.returnedFromCall(), + null) + .addDocumentation("read a token") + .addPossibleStatuses(200) + .secureWithApiKey("authToken", "X-AUTH-TOKEN")); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + + Assertions.assertEquals( + "#/components/schemas/" + Swaggerizer.THINGIFIER_ERROR_SCHEMA_NAME, + openApi.getPaths() + .get("/secret/token") + .getGet() + .getResponses() + .get("401") + .getContent() + .get("application/json") + .getSchema() + .get$ref()); + } + @Test void alternativeAuthRendersOpenApiSecurityRequirementsInDeclarationOrder() { final Thingifier thingifier = relationshipModel(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerStrongSchemaTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerStrongSchemaTest.java new file mode 100644 index 00000000..207b2d8f --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerStrongSchemaTest.java @@ -0,0 +1,160 @@ +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.PathItem; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import java.util.Set; +import java.util.stream.Collectors; +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.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; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.MaximumLengthValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.NotEmptyValidationRule; + +class SwaggerizerStrongSchemaTest { + + @Test + void openApi30StrongSchemaSerializesKnownFieldTypes() { + final JsonObject document = + JsonParser.parseString( + new Swaggerizer(apiDefn(todoModel())) + .asJson( + strongConfig( + OpenApiSpecificationVersion.OPENAPI_3_0))) + .getAsJsonObject(); + final JsonObject schemas = + document.getAsJsonObject("components").getAsJsonObject("schemas"); + final JsonObject createTodoProperties = + schemas.getAsJsonObject("create_todo").getAsJsonObject("properties"); + final JsonObject todoProperties = + schemas.getAsJsonObject("todo").getAsJsonObject("properties"); + + Assertions.assertEquals( + "string", createTodoProperties.getAsJsonObject("title").get("type").getAsString()); + Assertions.assertEquals( + "boolean", + createTodoProperties.getAsJsonObject("doneStatus").get("type").getAsString()); + Assertions.assertEquals( + "integer", todoProperties.getAsJsonObject("id").get("type").getAsString()); + } + + @Test + void strongSchemaDocumentsStringValidationConstraints() { + final JsonObject document = + JsonParser.parseString( + new Swaggerizer(apiDefn(todoModel())) + .asJson( + strongConfig( + OpenApiSpecificationVersion.OPENAPI_3_1))) + .getAsJsonObject(); + final JsonObject createTodo = + document.getAsJsonObject("components") + .getAsJsonObject("schemas") + .getAsJsonObject("create_todo"); + final JsonObject title = createTodo.getAsJsonObject("properties").getAsJsonObject("title"); + + Assertions.assertEquals( + "title", createTodo.getAsJsonArray("required").get(0).getAsString()); + Assertions.assertEquals(1, title.get("minLength").getAsInt()); + Assertions.assertEquals(50, title.get("maxLength").getAsInt()); + } + + @Test + void guidCustomHeaderUsesStringUuidSchema() { + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.addRouteToDocumentation( + new RoutingDefinition( + RoutingVerb.GET, + "/challenger", + RoutingStatus.returnedFromCall(), + null) + .addDocumentation("read challenger") + .addPossibleStatuses(200) + .addCustomHeader("X-CHALLENGER", "guid")); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + final Schema headerSchema = + openApi.getPaths().get("/challenger").getGet().getParameters().get(0).getSchema(); + + Assertions.assertEquals("string", headerSchema.getType()); + Assertions.assertEquals("uuid", headerSchema.getFormat()); + } + + @Test + void operationPathParameterModeMovesPathParametersOntoOperations() { + final SwaggerGenerationConfig config = new SwaggerGenerationConfig(); + config.pathParameterPlacement = SwaggerGenerationConfig.PathParameterPlacement.OPERATION; + final OpenAPI openApi = new Swaggerizer(apiDefn(todoModel())).swagger(config); + final PathItem path = openApi.getPaths().get("/todos/{id}"); + final Operation get = path.getGet(); + + Assertions.assertTrue(path.getParameters() == null || path.getParameters().isEmpty()); + Assertions.assertEquals(Set.of("id"), parameterNames(get)); + Assertions.assertEquals("integer", get.getParameters().get(0).getSchema().getType()); + } + + @Test + void generatedErrorResponsesUseThingifierErrorSchema() { + final OpenAPI openApi = new Swaggerizer(apiDefn(todoModel())).swagger(); + final Schema errorSchema = + openApi.getPaths() + .get("/todos/{id}") + .getGet() + .getResponses() + .get("404") + .getContent() + .get("application/json") + .getSchema(); + + Assertions.assertEquals( + "#/components/schemas/" + Swaggerizer.THINGIFIER_ERROR_SCHEMA_NAME, + errorSchema.get$ref()); + Assertions.assertTrue( + openApi.getComponents() + .getSchemas() + .get(Swaggerizer.THINGIFIER_ERROR_SCHEMA_NAME) + .getProperties() + .containsKey("errorMessages")); + } + + private Set parameterNames(final Operation operation) { + return operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + } + + 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 Thingifier todoModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition todo = thingifier.defineThing("todo", "todos"); + todo.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT).withExample("1")); + todo.addField( + Field.is("title", FieldType.STRING) + .makeMandatory() + .withValidation( + new NotEmptyValidationRule(), new MaximumLengthValidationRule(50))); + todo.addField(Field.is("doneStatus", FieldType.BOOLEAN).withExample("false")); + return thingifier; + } +}