diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 5657930b..c461a9fb 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -151,3 +151,51 @@ The validation can fail for formal reasons, such as the wrong format for a param However, validation can of course also fail because the content does not match the defined schema. In this case a {@link io.vertx.openapi.validation.SchemaValidationException} is thrown. It is a subclass of _ValidatorException_ and provides access to the related {@link io.vertx.json.schema.OutputUnit} to allow further analysis of the error. + +=== Support Custom Media Types + +Custom media types that are currently not supported out of the box can be used by implementing a custom mapping of those +media type to JSON. This is done by creating a custom instance of a {@link io.vertx.openapi.validation.analyser.ContentAnalyserFactory} +and a corresponding {@link io.vertx.openapi.validation.analyser.ContentAnalyser} implementation. + +In order to use those custom media types, they can be specified through +{@link io.vertx.openapi.contract.OpenAPIContractBuilder#registerSupportedMediaType}: + +[source,$lang] +---- +{@link examples.ContractExamples#customMediaTypes} +---- + +==== Unchecked (Opaque) Media Types + +It is also possible to register a media type that will be handled as an opaque string instead of transforming them into +JSON by calling{@link io.vertx.openapi.contract.OpenAPIContractBuilder#registerUncheckedMediaType}: + +[source,$lang] +---- +{@link examples.ContractExamples#uncheckedMediaTypes} +---- + +NOTE: If you specify a schema for any unchecked media type in your OpenAPI contract, the request or response validator +built from that contract might still attempt schema validation, which will likely fail. To disable schema validation, +you can set the schema to be an unrestricted binary string (see below) or by omitting the schema altogether (only +allowed in OpenAPI 3.1) + +[source,yaml] +---- +paths: + /sse: + get: + operationId: uncheckedMediaType + responses: + default: + description: An SSE stream + content: + # OpenAPI 3.0 and 3.1: Unrestricted binary schema + text/event-stream: + schema: + type: string + format: binary + # Only OpenAPI 3.1: + # text/event-stream: {} +---- diff --git a/src/main/java/examples/ContractExamples.java b/src/main/java/examples/ContractExamples.java index 92f1ca70..b5941ff1 100644 --- a/src/main/java/examples/ContractExamples.java +++ b/src/main/java/examples/ContractExamples.java @@ -18,6 +18,7 @@ import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.Path; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.HashMap; import java.util.Map; @@ -58,7 +59,30 @@ public void pathParameterOperationExample() { } } + public void customMediaTypes(Vertx vertx) { + ContentAnalyserFactory yamlFactory = getYamlFactory(); + + OpenAPIContract.builder(vertx) + .registerSupportedMediaType( + "application/yaml", + yamlFactory, + // Can specify more aliases for the same media type + "application/yml"); + } + + public void uncheckedMediaTypes(Vertx vertx) { + OpenAPIContract.builder(vertx) + .registerUncheckedMediaType( + "text/event-stream", + // Can specify more aliases for the same media type + "text/event-stream; charset=utf-8"); + } + private OpenAPIContract getContract() { return null; } + + private ContentAnalyserFactory getYamlFactory() { + return null; + } } diff --git a/src/main/java/io/vertx/openapi/contract/MediaType.java b/src/main/java/io/vertx/openapi/contract/MediaType.java index 3726afdb..43c4430a 100644 --- a/src/main/java/io/vertx/openapi/contract/MediaType.java +++ b/src/main/java/io/vertx/openapi/contract/MediaType.java @@ -15,7 +15,9 @@ import io.vertx.codegen.annotations.VertxGen; import io.vertx.json.schema.JsonSchema; import io.vertx.openapi.contract.impl.VendorSpecificJson; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.List; +import java.util.Map; /** * This interface represents the most important attributes of an OpenAPI Operation. @@ -37,8 +39,9 @@ public interface MediaType extends OpenAPIObject { List SUPPORTED_MEDIA_TYPES = List.of(APPLICATION_JSON, APPLICATION_JSON_UTF8, MULTIPART_FORM_DATA, APPLICATION_HAL_JSON, APPLICATION_OCTET_STREAM, TEXT_PLAIN, TEXT_PLAIN_UTF8); - static boolean isMediaTypeSupported(String type) { - return SUPPORTED_MEDIA_TYPES.contains(type.toLowerCase()) || isVendorSpecificJson(type); + static boolean isMediaTypeSupported(String type, Map additionalMediaTypes) { + return (additionalMediaTypes != null && additionalMediaTypes.containsKey(type.toLowerCase())) || + SUPPORTED_MEDIA_TYPES.contains(type.toLowerCase()) || isVendorSpecificJson(type); } static boolean isVendorSpecificJson(String type) { diff --git a/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java b/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java index 0fc05dd7..b418db5e 100644 --- a/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java +++ b/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java @@ -25,6 +25,8 @@ import io.vertx.json.schema.JsonSchemaValidationException; import io.vertx.openapi.contract.impl.OpenAPIContractImpl; import io.vertx.openapi.impl.Utils; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -57,6 +59,7 @@ public OpenAPIContractBuilderException(String message) { private JsonObject contract; private final Map additionalContractPartPaths = new HashMap<>(); private final Map additionalContractParts = new HashMap<>(); + private final Map additionalMediaTypes = new HashMap<>(); public OpenAPIContractBuilder(Vertx vertx) { this.vertx = vertx; @@ -153,6 +156,41 @@ public OpenAPIContractBuilder setAdditionalContractParts(Map return this; } + /** + * Registers a custom supported media type. By default, the OpenAPI contract only supports variants of JSON, + * multipart/form, text/plain and application/octet-stream. Custom media types can be supported by providing a + * mapping to JSON. This is done by implementing a ContentAnalyser and a ContentAnalyserFactory. See their Javadoc + * for details. + * + * @param mediaType the media type to register + * @param contentAnalyserFactory the custom JSON mapping for the registered media type + * @param aliases alternative names for the same media type (e.g. including a charset parameter) + * @return a reference to this for chaining + */ + public OpenAPIContractBuilder registerSupportedMediaType( + String mediaType, ContentAnalyserFactory contentAnalyserFactory, String... aliases + ) { + additionalMediaTypes.put(mediaType, contentAnalyserFactory); + Arrays.stream(aliases).forEach(alias -> additionalMediaTypes.put(alias, contentAnalyserFactory)); + return this; + } + + /** + * Registers support for a media type that is unchecked. Values of this media type are treated as opaque binary + * content. + * Note that validator instances built from this contract might still attempt schema validation if the + * contract specifies a schema for this media type, which will likely fail. This can only be addressed by designating + * the schema as a binary string (type=string, format=binary, no other properties) or by omitting the schema entirely + * (only OpenAPI 3.1+). + * + * @param mediaType the media type to register + * @param aliases alternative names for the same media type (e.g. including a charset parameter) + * @return a reference to this for chaining + */ + public OpenAPIContractBuilder registerUncheckedMediaType(String mediaType, String... aliases) { + return registerSupportedMediaType(mediaType, ContentAnalyserFactory.NO_OP, aliases); + } + /** * Builds the contract. * @@ -192,7 +230,7 @@ private Future buildOpenAPIContract() { return failedFuture(createInvalidContract(null, e)); } }) - .map(resolvedSpec -> new OpenAPIContractImpl(resolvedSpec, version, repository))) + .map(resolvedSpec -> new OpenAPIContractImpl(resolvedSpec, version, repository, additionalMediaTypes))) .recover(e -> { // Convert any non-openapi exceptions into an OpenAPIContractException if (e instanceof OpenAPIContractException) { diff --git a/src/main/java/io/vertx/openapi/contract/impl/MediaTypeImpl.java b/src/main/java/io/vertx/openapi/contract/impl/MediaTypeImpl.java index cbf82003..277a8bf6 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/MediaTypeImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/MediaTypeImpl.java @@ -17,6 +17,8 @@ import io.vertx.core.json.JsonObject; import io.vertx.json.schema.JsonSchema; import io.vertx.openapi.contract.MediaType; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; +import java.util.Map; public class MediaTypeImpl implements MediaType { private static final String KEY_SCHEMA = "schema"; @@ -25,7 +27,9 @@ public class MediaTypeImpl implements MediaType { private final JsonSchema schema; - public MediaTypeImpl(String identifier, JsonObject mediaTypeModel) { + private final ContentAnalyserFactory contentAnalyserFactory; + + public MediaTypeImpl(String identifier, JsonObject mediaTypeModel, Map additionalMediaTypes) { this.identifier = identifier; this.mediaTypeModel = mediaTypeModel; @@ -47,7 +51,12 @@ public MediaTypeImpl(String identifier, JsonObject mediaTypeModel) { throw createUnsupportedFeature("Media Type without a schema"); } schema = JsonSchema.of(schemaJson); + } + if (additionalMediaTypes != null) { + contentAnalyserFactory = additionalMediaTypes.get(identifier.toLowerCase()); + } else { + contentAnalyserFactory = null; } } @@ -65,4 +74,8 @@ public String getIdentifier() { public JsonObject getOpenAPIModel() { return mediaTypeModel; } + + public ContentAnalyserFactory getContentAnalyserFactory() { + return contentAnalyserFactory; + } } diff --git a/src/main/java/io/vertx/openapi/contract/impl/OpenAPIContractImpl.java b/src/main/java/io/vertx/openapi/contract/impl/OpenAPIContractImpl.java index e696eb7d..84e7ef58 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/OpenAPIContractImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/OpenAPIContractImpl.java @@ -35,6 +35,7 @@ import io.vertx.openapi.contract.SecurityRequirement; import io.vertx.openapi.contract.SecurityScheme; import io.vertx.openapi.contract.Server; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -46,7 +47,7 @@ public class OpenAPIContractImpl implements OpenAPIContract { private static final String KEY_SECURITY = "security"; private static final String PATH_PARAM_PLACEHOLDER_REGEX = "\\{(.*?)}"; private static final UnaryOperator ELIMINATE_PATH_PARAM_PLACEHOLDER = - path -> path.replaceAll(PATH_PARAM_PLACEHOLDER_REGEX, "{}"); + path -> path.replaceAll(PATH_PARAM_PLACEHOLDER_REGEX, "{}"); private final List servers; @@ -68,7 +69,7 @@ public class OpenAPIContractImpl implements OpenAPIContract { // VisibleForTesting final String basePath; - public OpenAPIContractImpl(JsonObject resolvedSpec, OpenAPIVersion version, SchemaRepository schemaRepository) { + public OpenAPIContractImpl(JsonObject resolvedSpec, OpenAPIVersion version, SchemaRepository schemaRepository, Map additionalMediaTypes) { this.rawContract = resolvedSpec; this.version = version; this.schemaRepository = schemaRepository; @@ -95,7 +96,7 @@ public OpenAPIContractImpl(JsonObject resolvedSpec, OpenAPIVersion version, Sche .stream() .filter(JsonSchema.EXCLUDE_ANNOTATION_ENTRIES) .map(pathEntry -> new PathImpl(basePath, pathEntry.getKey(), (JsonObject) pathEntry.getValue(), - securityRequirements)) + securityRequirements, additionalMediaTypes)) .collect(toList()); List sortedPaths = applyMountOrder(unsortedPaths); @@ -142,7 +143,7 @@ public static List applyMountOrder(List unsorted) { } withTemplating.sort(comparing(p -> ELIMINATE_PATH_PARAM_PLACEHOLDER.apply(p.getName()))); - withoutTemplating.sort(comparing(p -> ELIMINATE_PATH_PARAM_PLACEHOLDER.apply(p.getName()))); + withoutTemplating.sort(comparing(Path::getName)); // Check for Paths with same hierarchy but different templated names for (int x = 1; x < withTemplating.size(); x++) { @@ -156,7 +157,7 @@ public static List applyMountOrder(List unsorted) { throw createInvalidContract("Found Path duplicate: " + first); } else { throw createInvalidContract( - "Found Paths with same hierarchy but different templated names: " + firstWithoutPlaceHolder); + "Found Paths with same hierarchy but different templated names: " + firstWithoutPlaceHolder); } } } diff --git a/src/main/java/io/vertx/openapi/contract/impl/OperationImpl.java b/src/main/java/io/vertx/openapi/contract/impl/OperationImpl.java index 1c86a05b..9c948fed 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/OperationImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/OperationImpl.java @@ -34,6 +34,7 @@ import io.vertx.openapi.contract.RequestBody; import io.vertx.openapi.contract.Response; import io.vertx.openapi.contract.SecurityRequirement; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -67,7 +68,7 @@ public class OperationImpl implements Operation { public OperationImpl(String absolutePath, String path, HttpMethod method, JsonObject operationModel, List pathParameters, Map pathExtensions, - List globalSecReq) { + List globalSecReq, Map additionalMediaTypes) { this.absolutePath = absolutePath; this.operationId = operationModel.getString(KEY_OPERATION_ID); this.method = method; @@ -119,7 +120,7 @@ public OperationImpl(String absolutePath, String path, HttpMethod method, JsonOb if (requestBodyJson == null || requestBodyJson.isEmpty()) { this.requestBody = null; } else { - this.requestBody = new RequestBodyImpl(requestBodyJson, operationId); + this.requestBody = new RequestBodyImpl(requestBodyJson, operationId, additionalMediaTypes); } JsonObject responsesJson = operationModel.getJsonObject(KEY_RESPONSES, EMPTY_JSON_OBJECT); @@ -128,7 +129,8 @@ public OperationImpl(String absolutePath, String path, HttpMethod method, JsonOb throw createInvalidContract(msg); } defaultResponse = responsesJson.stream().filter(entry -> "default".equalsIgnoreCase(entry.getKey())).findFirst() - .map(entry -> new ResponseImpl((JsonObject) entry.getValue(), operationId)).orElse(null); + .map(entry -> + new ResponseImpl((JsonObject) entry.getValue(), operationId, additionalMediaTypes)).orElse(null); responses = unmodifiableMap( responsesJson @@ -137,7 +139,9 @@ public OperationImpl(String absolutePath, String path, HttpMethod method, JsonOb .filter(JsonSchema.EXCLUDE_ANNOTATIONS) .filter(RESPONSE_CODE_PATTERN.asPredicate()) .collect( - toMap(Integer::parseInt, key -> new ResponseImpl(responsesJson.getJsonObject(key), operationId)))); + toMap( + Integer::parseInt, + key -> new ResponseImpl(responsesJson.getJsonObject(key), operationId, additionalMediaTypes)))); } @Override diff --git a/src/main/java/io/vertx/openapi/contract/impl/PathImpl.java b/src/main/java/io/vertx/openapi/contract/impl/PathImpl.java index 802ce572..e019b9ea 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/PathImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/PathImpl.java @@ -31,6 +31,7 @@ import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.Path; import io.vertx.openapi.contract.SecurityRequirement; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -62,7 +63,7 @@ public class PathImpl implements Path { private final JsonObject pathModel; private final String absolutePath; - public PathImpl(String basePath, String name, JsonObject pathModel, List globalSecReq) { + public PathImpl(String basePath, String name, JsonObject pathModel, List globalSecReq, Map additionalMediaTypes) { this.absolutePath = (basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath) + name; this.pathModel = pathModel; if (name.contains("*")) { @@ -82,7 +83,7 @@ public PathImpl(String basePath, String name, JsonObject pathModel, List ops = new ArrayList<>(); SUPPORTED_METHODS.forEach((methodName, method) -> Optional.ofNullable(pathModel.getJsonObject(methodName)) .map(operationModel -> new OperationImpl(absolutePath, name, method, operationModel, parameters, - getExtensions(), globalSecReq)) + getExtensions(), globalSecReq, additionalMediaTypes)) .ifPresent(ops::add)); this.operations = unmodifiableList(ops); } diff --git a/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java b/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java index d125ea77..95614eee 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java @@ -25,6 +25,7 @@ import io.vertx.json.schema.JsonSchema; import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.RequestBody; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.Map; public class RequestBodyImpl implements RequestBody { @@ -37,7 +38,7 @@ public class RequestBodyImpl implements RequestBody { private final Map content; - public RequestBodyImpl(JsonObject requestBodyModel, String operationId) { + public RequestBodyImpl(JsonObject requestBodyModel, String operationId, Map additionalMediaTypes) { this.requestBodyModel = requestBodyModel; this.required = requestBodyModel.getBoolean(KEY_REQUIRED, false); JsonObject contentObject = requestBodyModel.getJsonObject(KEY_CONTENT, EMPTY_JSON_OBJECT); @@ -48,14 +49,14 @@ public RequestBodyImpl(JsonObject requestBodyModel, String operationId) { .stream() .filter(JsonSchema.EXCLUDE_ANNOTATIONS) .filter(mediaTypeIdentifier -> { - if (isMediaTypeSupported(mediaTypeIdentifier)) { + if (isMediaTypeSupported(mediaTypeIdentifier, additionalMediaTypes)) { return true; } String msgTemplate = "Operation %s defines a request body with an unsupported media type. Supported: %s"; throw createUnsupportedFeature( String.format(msgTemplate, operationId, join(", ", SUPPORTED_MEDIA_TYPES))); }) - .collect(toMap(this::removeWhiteSpaces, key -> new MediaTypeImpl(key, contentObject.getJsonObject(key))))); + .collect(toMap(this::removeWhiteSpaces, key -> new MediaTypeImpl(key, contentObject.getJsonObject(key), additionalMediaTypes)))); if (content.isEmpty()) { String msg = diff --git a/src/main/java/io/vertx/openapi/contract/impl/ResponseImpl.java b/src/main/java/io/vertx/openapi/contract/impl/ResponseImpl.java index 3ed58fb5..17a01c83 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/ResponseImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/ResponseImpl.java @@ -29,6 +29,7 @@ import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.Response; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.List; import java.util.Map; import java.util.function.Predicate; @@ -45,7 +46,7 @@ public class ResponseImpl implements Response { private final JsonObject responseModel; - public ResponseImpl(JsonObject responseModel, String operationId) { + public ResponseImpl(JsonObject responseModel, String operationId, Map additionalMediaTypes) { this.responseModel = responseModel; JsonObject headersObject = responseModel.getJsonObject(KEY_HEADERS, EMPTY_JSON_OBJECT); @@ -66,9 +67,9 @@ public ResponseImpl(JsonObject responseModel, String operationId) { .fieldNames() .stream() .filter(JsonSchema.EXCLUDE_ANNOTATIONS) - .collect(toMap(identity(), key -> new MediaTypeImpl(key, contentObject.getJsonObject(key))))); + .collect(toMap(identity(), key -> new MediaTypeImpl(key, contentObject.getJsonObject(key), additionalMediaTypes)))); - if (content.keySet().stream().anyMatch(type -> !isMediaTypeSupported(type))) { + if (content.keySet().stream().anyMatch(type -> !isMediaTypeSupported(type, additionalMediaTypes))) { String msgTemplate = "Operation %s defines a response with an unsupported media type. Supported: %s"; throw createUnsupportedFeature(String.format(msgTemplate, operationId, join(", ", SUPPORTED_MEDIA_TYPES))); } diff --git a/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java index 60781d2d..dba37082 100644 --- a/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java +++ b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java @@ -18,6 +18,7 @@ import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.openapi.contract.MediaType; +import io.vertx.openapi.contract.impl.MediaTypeImpl; import io.vertx.openapi.validation.ValidationContext; import io.vertx.openapi.validation.ValidatorException; @@ -34,7 +35,7 @@ * before. */ public abstract class ContentAnalyser { - private static class NoOpAnalyser extends ContentAnalyser { + static class NoOpAnalyser extends ContentAnalyser { public NoOpAnalyser(String contentType, Buffer content, ValidationContext context) { super(contentType, content, context); } @@ -51,15 +52,23 @@ public Object transform() { } /** - * Returns the content analyser for the given content type. + * Returns the content analyser for the given content type. If the media type has a custom content analyser factory, + * it will be used to create the content analyser. * * @param mediaType the media type to determine the content analyser. * @param contentType the raw content type value from the HTTP header field. * @param content the content to be analysed. * @return the content analyser for the given content type. */ - public static ContentAnalyser getContentAnalyser(MediaType mediaType, String contentType, Buffer content, - ValidationContext context) { + public static ContentAnalyser getContentAnalyser(MediaType mediaType, String contentType, Buffer content, ValidationContext context) { + if (mediaType instanceof MediaTypeImpl) { + ContentAnalyserFactory factory = ((MediaTypeImpl) mediaType).getContentAnalyserFactory(); + + if (factory != null) { + return factory.getContentAnalyser(contentType, content, context); + } + } + switch (mediaType.getIdentifier()) { case MediaType.APPLICATION_JSON: case MediaType.APPLICATION_JSON_UTF8: diff --git a/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyserFactory.java b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyserFactory.java new file mode 100644 index 00000000..1533c56d --- /dev/null +++ b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyserFactory.java @@ -0,0 +1,38 @@ +package io.vertx.openapi.validation.analyser; + +import io.vertx.codegen.annotations.GenIgnore; +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.validation.ValidationContext; + +/** + * A factory to create content analysers for custom media types. Two common instances are provided: + * + *
  • ContentAnalyserFactory.NO_OP: A content analyser factory that treats input as an opaque octet string
  • + *
  • ContentAnalyserFactory.JSON: A content analyser factory that treats input as JSON and decodes it for validation
  • + */ +@VertxGen +public interface ContentAnalyserFactory { + /** + * A factory creating no-op content analysers that treat content as an opaque octet string. It can be used for content + * types that do not require or support schema validation. + */ + ContentAnalyserFactory NO_OP = ContentAnalyser.NoOpAnalyser::new; + + /** + * A factory creating content analysers that treat the content as JSON and decode it for schema validation. + */ + ContentAnalyserFactory JSON = ApplicationJsonAnalyser::new; + + /** + * Returns a content analyser for the given content type, working over the given content in the context of a response + * or request, as indicated by the context parameter. + * + * @param contentType the content type to return an analyzer for + * @param content the raw request or response body content + * @param context the validation context (REQUEST or RESPONSE) + * @return a content analyser for the given content type and content + */ + @GenIgnore + ContentAnalyser getContentAnalyser(String contentType, Buffer content, ValidationContext context); +} diff --git a/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java b/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java index 8d0122ed..5d6d35b2 100644 --- a/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java +++ b/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java @@ -21,8 +21,11 @@ import io.vertx.openapi.contract.OpenAPIContract; import io.vertx.openapi.contract.OpenAPIContractBuilder; import io.vertx.openapi.contract.OpenAPIContractException; +import io.vertx.openapi.contract.impl.MediaTypeImpl; import io.vertx.openapi.impl.Utils; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import io.vertx.tests.ResourceHelper; +import io.vertx.tests.validation.impl.RequestValidatorImplTest; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; @@ -235,4 +238,125 @@ void set_additional_contract_paths_should_replace_existing_contract_paths(Vertx } } + @Nested + @ExtendWith(VertxExtension.class) + class TestAdditionalMediaTypes { + private final JsonObject CONTRACT = + new JsonObject( + "{\n" + + " \"openapi\": \"3.1.0\",\n" + + " \"info\": {\"version\": \"1.0.0\", \"title\": \"Swagger Petstore\", \"license\": {\"identifier\": \"MIT\", \"name\": \"MIT License\"}},\n" + + " \"paths\": {\n" + + " \"/pets\": {\n" + + " \"get\": {\n" + + " \"summary\": \"List Pets\"," + + " \"operationId\": \"listPets\"," + + " \"responses\": {\n" + + " \"default\": {\n" + + " \"description\": \"Default Response\",\n" + + " \"content\": {\n" + + " \"application/yml\": {\n" + + " \"schema\": {\n" + + " \"type\": \"object\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " },\n" + + " \"post\": {\n" + + " \"summary\": \"Create Pets\"," + + " \"operationId\": \"createPets\"," + + " \"requestBody\": {\n" + + " \"required\": true,\n" + + " \"content\": {\n" + + " \"application/yaml\": {\n" + + " \"schema\": {\n" + + " \"type\": \"object\"\n" + + " }\n" + + " }\n" + + " }\n" + + " },\n" + + " \"responses\": {\n" + + " \"default\": {\n" + + " \"description\": \"Default Response\",\n" + + " \"content\": {\n" + + " \"application/json\": {\n" + + " \"schema\": {\n" + + " \"type\": \"object\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " },\n" + + " \"/pets/updates\": {\n" + + " \"get\": {\n" + + " \"summary\": \"Listen to pet update events\"," + + " \"operationId\": \"petEvents\"," + + " \"responses\": {\n" + + " \"default\": {\n" + + " \"description\": \"Stream of Pet Events\",\n" + + " \"content\": {\n" + + " \"text/event-stream\": {}\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}" + ); + @Test + void should_accept_additional_media_types(Vertx vertx, VertxTestContext ctx) { + OpenAPIContract.builder(vertx) + .setContract(CONTRACT) + .registerSupportedMediaType( + "application/yml", + new RequestValidatorImplTest.YamlContentAnalyzerFactory(), + "application/yaml" + ) + .registerUncheckedMediaType("text/event-stream") + .build() + .onComplete(ctx.succeeding(c -> ctx.verify(() -> { + var listPets = c.operation("listPets"); + + assertThat(listPets.getDefaultResponse().getContent()).containsKey("application/yml"); + assertThat(listPets.getDefaultResponse().getContent().get("application/yml")) + .isInstanceOf(MediaTypeImpl.class); + assertThat( + ((MediaTypeImpl) listPets.getDefaultResponse().getContent().get("application/yml")) + .getContentAnalyserFactory()).isNotNull(); + assertThat( + ((MediaTypeImpl) listPets.getDefaultResponse().getContent().get("application/yml")) + .getContentAnalyserFactory()).isInstanceOf(RequestValidatorImplTest.YamlContentAnalyzerFactory.class); + + var createPets = c.operation("createPets"); + + assertThat(createPets.getRequestBody().getContent()).containsKey("application/yaml"); + assertThat(createPets.getRequestBody().getContent().get("application/yaml")) + .isInstanceOf(MediaTypeImpl.class); + assertThat( + ((MediaTypeImpl) createPets.getRequestBody().getContent().get("application/yaml")) + .getContentAnalyserFactory()).isNotNull(); + assertThat( + ((MediaTypeImpl) createPets.getRequestBody().getContent().get("application/yaml")) + .getContentAnalyserFactory()).isInstanceOf(RequestValidatorImplTest.YamlContentAnalyzerFactory.class); + ctx.completeNow(); + + var petEvents = c.operation("petEvents"); + + assertThat(petEvents.getDefaultResponse().getContent()).containsKey("text/event-stream"); + assertThat(petEvents.getDefaultResponse().getContent().get("text/event-stream")) + .isInstanceOf(MediaTypeImpl.class); + assertThat( + ((MediaTypeImpl) petEvents.getDefaultResponse().getContent().get("text/event-stream")) + .getContentAnalyserFactory()).isNotNull(); + assertThat( + ((MediaTypeImpl) petEvents.getDefaultResponse().getContent().get("text/event-stream")) + .getContentAnalyserFactory()).isEqualTo(ContentAnalyserFactory.NO_OP); + }))); + } + } } diff --git a/src/test/java/io/vertx/tests/contract/impl/MediaTypeImplTest.java b/src/test/java/io/vertx/tests/contract/impl/MediaTypeImplTest.java index 3fe7cc4c..794b8cd4 100644 --- a/src/test/java/io/vertx/tests/contract/impl/MediaTypeImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/MediaTypeImplTest.java @@ -16,14 +16,24 @@ import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; import static io.vertx.json.schema.common.dsl.Schemas.stringSchema; import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.openapi.contract.ContractErrorType; import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.impl.MediaTypeImpl; +import io.vertx.openapi.validation.ValidationContext; +import io.vertx.openapi.validation.analyser.ContentAnalyser; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -71,7 +81,7 @@ private static Stream testGetters() { @ParameterizedTest(name = "{index} test getters for scenario: {0}") @MethodSource void testGetters(String scenario, JsonObject mediaTypeModel, List fieldNames) { - MediaType mediaType = new MediaTypeImpl(DUMMY_IDENTIFIER, mediaTypeModel); + MediaType mediaType = new MediaTypeImpl(DUMMY_IDENTIFIER, mediaTypeModel, emptyMap()); assertThat(mediaType.getOpenAPIModel()).isEqualTo(mediaTypeModel); if (fieldNames.isEmpty()) { assertThat(mediaType.getSchema()).isNull(); @@ -86,20 +96,68 @@ void testExceptions() { String msg = "The passed OpenAPI contract contains a feature that is not supported: Media Type without a schema"; OpenAPIContractException exceptionNoModel = - assertThrows(OpenAPIContractException.class, () -> new MediaTypeImpl(DUMMY_IDENTIFIER, null)); + assertThrows(OpenAPIContractException.class, () -> new MediaTypeImpl(DUMMY_IDENTIFIER, null, emptyMap())); assertThat(exceptionNoModel.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); assertThat(exceptionNoModel).hasMessageThat().isEqualTo(msg); OpenAPIContractException exceptionSchemaNull = assertThrows(OpenAPIContractException.class, () -> new MediaTypeImpl(DUMMY_IDENTIFIER, - new JsonObject().putNull("schema"))); + new JsonObject().putNull("schema"), emptyMap())); assertThat(exceptionSchemaNull.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); assertThat(exceptionSchemaNull).hasMessageThat().isEqualTo(msg); OpenAPIContractException exceptionSchemaEmpty = assertThrows(OpenAPIContractException.class, - () -> new MediaTypeImpl(DUMMY_IDENTIFIER, new JsonObject().put("schema", EMPTY_JSON_OBJECT))); + () -> new MediaTypeImpl(DUMMY_IDENTIFIER, new JsonObject().put("schema", EMPTY_JSON_OBJECT), emptyMap())); assertThat(exceptionSchemaEmpty.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); assertThat(exceptionSchemaEmpty).hasMessageThat().isEqualTo(msg); } + + @Test + void testCustomMediaTypes() { + var mt1 = new MediaTypeImpl( + "text/event-stream", + JsonObject.of("schema", stringSchema().toJson()), + Map.of("text/event-stream", DummyContentAnalyzer.FACTORY, "application/xml", ContentAnalyserFactory.NO_OP) + ); + + ContentAnalyserFactory factory = mt1.getContentAnalyserFactory(); + assertNotNull(factory); + ContentAnalyser analyser = factory.getContentAnalyser("text/event-stream", Buffer.buffer("Hello world!"), null); + assertInstanceOf(DummyContentAnalyzer.class, analyser); + assertEquals(Buffer.buffer("Hello world!"), analyser.transform()); + + var mt2 = new MediaTypeImpl( + "application/xml", + JsonObject.of("schema", stringSchema().toJson()), + Map.of("text/event-stream", ContentAnalyserFactory.NO_OP) + ); + + assertNull(mt2.getContentAnalyserFactory()); + } + + public static class DummyContentAnalyzer extends ContentAnalyser { + public static final ContentAnalyserFactory FACTORY = DummyContentAnalyzer::new; + + /** + * Creates a new content analyser. + * + * @param contentType the content type. + * @param content the content to be analysed. + * @param context the context in which the content is used. + */ + public DummyContentAnalyzer(String contentType, Buffer content, ValidationContext context) { + super(contentType, content, context); + } + + @Override + public void checkSyntacticalCorrectness() { + // For testing purposes, this is a no-op + } + + @Override + public Object transform() { + return content; + } + } } diff --git a/src/test/java/io/vertx/tests/contract/impl/OpenAPIContractImplTest.java b/src/test/java/io/vertx/tests/contract/impl/OpenAPIContractImplTest.java index abf795b2..a95f4a25 100644 --- a/src/test/java/io/vertx/tests/contract/impl/OpenAPIContractImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/OpenAPIContractImplTest.java @@ -19,6 +19,7 @@ import static io.vertx.openapi.contract.OpenAPIVersion.V3_1; import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT; import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertThrows; import io.vertx.core.buffer.Buffer; @@ -29,6 +30,7 @@ import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.impl.OpenAPIContractImpl; import io.vertx.openapi.contract.impl.PathImpl; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import io.vertx.tests.ResourceHelper; import java.io.IOException; import java.nio.file.Files; @@ -37,6 +39,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; @@ -53,32 +56,32 @@ class OpenAPIContractImplTest { private static final Path VALID_CONTRACTS_JSON = RESOURCE_PATH.resolve("contract_valid.json"); private static final List PATHS_UNSORTED = Arrays.asList( - new PathImpl(BASE_PATH, "/v2", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/{abc}/pets/{petId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/{abc}/{foo}/bar", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/v1/docs/docId", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/pets/petId", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/v1/docs/{docId}", EMPTY_JSON_OBJECT, emptyList())); + new PathImpl(BASE_PATH, "/v2", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/{abc}/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/{abc}/{foo}/bar", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/v1/docs/docId", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/pets/petId", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/v1/docs/{docId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap())); private static final List PATHS_SORTED = Arrays.asList( - new PathImpl(BASE_PATH, "/pets/petId", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/v1/docs/docId", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/v2", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/v1/docs/{docId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/{abc}/pets/{petId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/{abc}/{foo}/bar", EMPTY_JSON_OBJECT, emptyList())); + new PathImpl(BASE_PATH, "/pets/petId", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/v1/docs/docId", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/v2", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/v1/docs/{docId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/{abc}/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/{abc}/{foo}/bar", EMPTY_JSON_OBJECT, emptyList(), emptyMap())); private static Stream testApplyMountOrderThrows() { return Stream.of( Arguments.of("a duplicate has been found", Arrays.asList( - new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList())), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap())), "Found Path duplicate: /pets/{petId}"), Arguments.of("paths with same hierarchy but different templated names has been found", Arrays.asList( - new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList()), - new PathImpl(BASE_PATH, "/pets/{foo}", EMPTY_JSON_OBJECT, emptyList())), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), emptyMap()), + new PathImpl(BASE_PATH, "/pets/{foo}", EMPTY_JSON_OBJECT, emptyList(), emptyMap())), "Found Paths with same hierarchy but different templated names: /pets/{}")); } @@ -108,7 +111,7 @@ void testDifferentBasePaths() { JsonObject contract = new JsonObject().put("servers", new JsonArray().add(server1).add(server2)); OpenAPIContractException exception = - assertThrows(OpenAPIContractException.class, () -> new OpenAPIContractImpl(contract, null, null)); + assertThrows(OpenAPIContractException.class, () -> new OpenAPIContractImpl(contract, null, null, null)); String expectedMsg = "The passed OpenAPI contract contains a feature that is not supported: Different base paths in server urls"; assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); @@ -120,10 +123,10 @@ void testBasePath() { JsonObject server1 = new JsonObject().put("url", "http://foo.bar/foo"); JsonObject contractJson = new JsonObject().put("servers", new JsonArray().add(server1)); - OpenAPIContractImpl contract = new OpenAPIContractImpl(contractJson, null, null); + OpenAPIContractImpl contract = new OpenAPIContractImpl(contractJson, null, null, null); assertThat(contract.basePath()).isEqualTo("/foo"); - OpenAPIContractImpl contractEmpty = new OpenAPIContractImpl(new JsonObject(), null, null); + OpenAPIContractImpl contractEmpty = new OpenAPIContractImpl(new JsonObject(), null, null, null); assertThat(contractEmpty.basePath()).isEqualTo(""); } @@ -133,7 +136,7 @@ void testGetters() throws IOException { Buffer.buffer(Files.readAllBytes(VALID_CONTRACTS_JSON)).toJsonObject().getJsonObject("0000_Test_Getters"); JsonObject resolvedSpec = testDataObject.getJsonObject("contractModel"); SchemaRepository schemaRepository = Mockito.mock(SchemaRepository.class); - OpenAPIContractImpl contract = new OpenAPIContractImpl(resolvedSpec, V3_1, schemaRepository); + OpenAPIContractImpl contract = new OpenAPIContractImpl(resolvedSpec, V3_1, schemaRepository, null); assertThat(contract.getServers()).hasSize(1); assertThat(contract.getServers().get(0).getURL()).isEqualTo("https://petstore.swagger.io/v1"); @@ -168,11 +171,35 @@ void testGettersEmptySecurityRequirements() throws IOException { assertThat(showPetById.getSecurityRequirements()).isEmpty(); } + @Test + void testCustomMediaTypes() throws IOException { + OpenAPIContractImpl contract = fromTestData( + "0002_Test_Custom_Media_Types", + Map.of("application/xml", ContentAnalyserFactory.NO_OP, "application/yml", ContentAnalyserFactory.NO_OP) + ); + + Operation listPets = contract.operation("listPets"); + assertThat(listPets).isNotNull(); + assertThat(listPets.getResponse(200).getContent()).hasSize(2); + assertThat(listPets.getResponse(200).getContent()).containsKey("application/json"); + assertThat(listPets.getResponse(200).getContent()).containsKey("application/xml"); + + Operation createPets = contract.operation("createPets"); + assertThat(createPets).isNotNull(); + assertThat(createPets.getRequestBody().getContent()).hasSize(2); + assertThat(createPets.getRequestBody().getContent()).containsKey("application/json"); + assertThat(createPets.getRequestBody().getContent()).containsKey("application/yml"); + } + private static OpenAPIContractImpl fromTestData(String testId) throws IOException { + return fromTestData(testId, null); + } + + private static OpenAPIContractImpl fromTestData(String testId, Map additionalMediaTypes) throws IOException { JsonObject testDataObject = Buffer.buffer(Files.readAllBytes(VALID_CONTRACTS_JSON)).toJsonObject().getJsonObject(testId); JsonObject resolvedSpec = testDataObject.getJsonObject("contractModel"); SchemaRepository schemaRepository = Mockito.mock(SchemaRepository.class); - return new OpenAPIContractImpl(resolvedSpec, V3_1, schemaRepository); + return new OpenAPIContractImpl(resolvedSpec, V3_1, schemaRepository, additionalMediaTypes); } } diff --git a/src/test/java/io/vertx/tests/contract/impl/OperationImplTest.java b/src/test/java/io/vertx/tests/contract/impl/OperationImplTest.java index 2f59c330..58ad0628 100644 --- a/src/test/java/io/vertx/tests/contract/impl/OperationImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/OperationImplTest.java @@ -21,7 +21,9 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.common.collect.ImmutableMap; import io.vertx.core.Vertx; @@ -31,17 +33,20 @@ import io.vertx.junit5.Timeout; import io.vertx.junit5.VertxExtension; import io.vertx.openapi.contract.ContractErrorType; +import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.RequestBody; import io.vertx.openapi.contract.impl.OperationImpl; import io.vertx.openapi.contract.impl.SecurityRequirementImpl; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import io.vertx.tests.ResourceHelper; import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -85,7 +90,7 @@ private static OperationImpl fromTestData(String id, JsonObject testData, Securi JsonObject operationModel = testDataObject.getJsonObject("operationModel"); List pathParams = parseParameters(path, testDataObject.getJsonArray("pathParams", EMPTY_JSON_ARRAY)); return new OperationImpl("/absolute" + path, path, method, operationModel, pathParams, emptyMap(), - Arrays.asList(secReqs)); + Arrays.asList(secReqs), emptyMap()); } @ParameterizedTest(name = "{index} should throw an exception for scenario: {0}") @@ -175,8 +180,51 @@ private static Stream providePathExtensions() { void testMergeExtensions(String scenario, Map pathExtensions, Map expected) { JsonObject testDataObject = validTestData.getJsonObject("0005_Test_Merge_Extensions"); JsonObject operationModel = testDataObject.getJsonObject("operationModel"); - Operation op = new OperationImpl("/", "path", GET, operationModel, emptyList(), pathExtensions, emptyList()); + Operation op = new OperationImpl("/", "path", GET, operationModel, emptyList(), pathExtensions, emptyList(), emptyMap()); assertThat(op.getExtensions()).containsExactlyEntriesIn(expected); } + + @Test + void testCustomMediaType() { + JsonObject operationModel = validTestData.getJsonObject("0006_Test_RequestBody_Custom_Media_type") + .getJsonObject("operationModel"); + OperationImpl operation = + new OperationImpl("/", "path", GET, operationModel, emptyList(), emptyMap(), emptyList(), + Map.of("text/event-stream", ContentAnalyserFactory.NO_OP)); + Map mediaTypes = operation.getRequestBody().getContent(); + + assertEquals(2, mediaTypes.size()); + assertEquals(Set.of("application/json", "text/event-stream"), mediaTypes.keySet()); + + mediaTypes = operation.getDefaultResponse().getContent(); + + assertEquals(2, mediaTypes.size()); + assertEquals(Set.of("application/json", "text/event-stream"), mediaTypes.keySet()); + } + + @Test + void testUnsupportedCustomMediaType() { + JsonObject operationModel = validTestData.getJsonObject("0006_Test_RequestBody_Custom_Media_type") + .getJsonObject("operationModel"); + String id = operationModel.getString("operationId"); + + OpenAPIContractException ex = assertThrows( + OpenAPIContractException.class, + () -> new OperationImpl("/", "path", GET, operationModel, emptyList(), emptyMap(), emptyList(),null) + ); + assertTrue(ex.getMessage().contains("Operation " + id + " defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new OperationImpl("/", "path", GET, operationModel, emptyList(), emptyMap(), emptyList(), emptyMap()) + ); + assertTrue(ex.getMessage().contains("Operation " + id + " defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new OperationImpl("/", "path", GET, operationModel, emptyList(), emptyMap(), emptyList(), Map.of("application/xml", ContentAnalyserFactory.NO_OP)) + ); + assertTrue(ex.getMessage().contains("Operation " + id + " defines a request body with an unsupported media type")); + } } diff --git a/src/test/java/io/vertx/tests/contract/impl/PathImplTest.java b/src/test/java/io/vertx/tests/contract/impl/PathImplTest.java index a32fe8ce..cf7f8e15 100644 --- a/src/test/java/io/vertx/tests/contract/impl/PathImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/PathImplTest.java @@ -16,16 +16,24 @@ import static io.vertx.openapi.contract.impl.PathImpl.INVALID_CURLY_BRACES; import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT; import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; import io.vertx.core.json.JsonObject; import io.vertx.junit5.VertxExtension; +import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.impl.PathImpl; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import io.vertx.tests.ResourceHelper; import java.nio.file.Path; +import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -49,7 +57,7 @@ private static PathImpl fromTestData(String id, JsonObject testData) { JsonObject testDataObject = testData.getJsonObject(id); String name = testDataObject.getString("name"); JsonObject pathModel = testDataObject.getJsonObject("pathModel"); - return new PathImpl(BASE_PATH, name, pathModel, emptyList()); + return new PathImpl(BASE_PATH, name, pathModel, emptyList(), emptyMap()); } @Test @@ -74,18 +82,18 @@ void testGetters() { void testWildcardInPath() { OpenAPIContractException exception = assertThrows(OpenAPIContractException.class, () -> new PathImpl(BASE_PATH, "/pets/*", EMPTY_JSON_OBJECT, - emptyList())); + emptyList(), emptyMap())); String expectedMsg = "The passed OpenAPI contract is invalid: Paths must not have a wildcard (asterisk): /pets/*"; assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); } @ParameterizedTest(name = "{index} wrong position of curley braces in path: {0}") - @ValueSource(strings = { "/foo{param}/", "/foo{param}", "/{param}bar/", "/{param}bar", "/foo{param}bar/", - "/foo{param}bar" }) + @ValueSource(strings = {"/foo{param}/", "/foo{param}", "/{param}bar/", "/{param}bar", "/foo{param}bar/", + "/foo{param}bar"}) void testWrongCurlyBracesInPath(String path) { OpenAPIContractException exception = assertThrows(OpenAPIContractException.class, - () -> new PathImpl(BASE_PATH, path, EMPTY_JSON_OBJECT, emptyList())); + () -> new PathImpl(BASE_PATH, path, EMPTY_JSON_OBJECT, emptyList(), emptyMap())); String expectedMsg = "The passed OpenAPI contract is invalid: Curly brace MUST be the first/last character in a path segment " + "(/{parameterName}/): " + path; @@ -101,19 +109,78 @@ void testValidCurlyBracesInPath(String path) { @Test void testCutTrailingSlash() { String expected = "/pets"; - assertThat(new PathImpl(BASE_PATH, expected + "/", EMPTY_JSON_OBJECT, emptyList()).getName()).isEqualTo(expected); + assertThat(new PathImpl(BASE_PATH, expected + "/", EMPTY_JSON_OBJECT, emptyList(), emptyMap()).getName()).isEqualTo(expected); } @Test void testRootPath() { String expected = "/"; - assertThat(new PathImpl(BASE_PATH, expected, EMPTY_JSON_OBJECT, emptyList()).getName()).isEqualTo(expected); + assertThat(new PathImpl(BASE_PATH, expected, EMPTY_JSON_OBJECT, emptyList(), emptyMap()).getName()).isEqualTo(expected); } @Test void testGetAbsolutePath() { String expected = "/base/foo"; - assertThat(new PathImpl("/base", "/foo", EMPTY_JSON_OBJECT, emptyList()).getAbsolutePath()).isEqualTo(expected); - assertThat(new PathImpl("/base/", "/foo", EMPTY_JSON_OBJECT, emptyList()).getAbsolutePath()).isEqualTo(expected); + assertThat(new PathImpl("/base", "/foo", EMPTY_JSON_OBJECT, emptyList(), emptyMap()).getAbsolutePath()).isEqualTo(expected); + assertThat(new PathImpl("/base/", "/foo", EMPTY_JSON_OBJECT, emptyList(), emptyMap()).getAbsolutePath()).isEqualTo(expected); + } + + @Test + void testCustomMediaType() { + JsonObject testObject = validTestData.getJsonObject("0001_Test_Custom_Media_Type"); + String path = testObject.getString("name"); + JsonObject pathModel = testObject.getJsonObject("pathModel"); + + PathImpl pathImpl = new PathImpl("/", path, pathModel, emptyList(), + Map.of("text/event-stream", ContentAnalyserFactory.NO_OP, "application/xml", ContentAnalyserFactory.NO_OP)); + + pathImpl.getOperations().forEach(op -> { + Map mediaTypes; + + if (op.getHttpMethod() == HttpMethod.GET) { + mediaTypes = op.getDefaultResponse().getContent(); + + assertEquals(2, mediaTypes.size()); + assertEquals(Set.of("application/json", "text/event-stream"), mediaTypes.keySet()); + } else { + mediaTypes = op.getRequestBody().getContent(); + + assertEquals(1, mediaTypes.size()); + assertEquals(Set.of("application/xml"), mediaTypes.keySet()); + } + }); + } + + @Test + void testUnsupportedCustomMediaType() { + JsonObject testObject = validTestData.getJsonObject("0001_Test_Custom_Media_Type"); + String path = testObject.getString("name"); + JsonObject pathModel = testObject.getJsonObject("pathModel"); + + OpenAPIContractException ex = assertThrows( + OpenAPIContractException.class, + () -> new PathImpl("/", path, pathModel, emptyList(), null) + ); + assertTrue(ex.getMessage().contains("Operation updatePet defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new PathImpl("/", path, pathModel, emptyList(), emptyMap()) + ); + assertTrue(ex.getMessage().contains("Operation updatePet defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new PathImpl("/", path, pathModel, emptyList(), + Map.of("text/event-stream", ContentAnalyserFactory.NO_OP)) + ); + assertTrue(ex.getMessage().contains("Operation updatePet defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new PathImpl("/", path, pathModel, emptyList(), + Map.of("application/xml", ContentAnalyserFactory.NO_OP)) + ); + assertTrue(ex.getMessage().contains("Operation showPetById defines a response with an unsupported media type")); } } diff --git a/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java b/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java index 413eeeb6..52293f2e 100644 --- a/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java @@ -18,16 +18,23 @@ import static io.vertx.openapi.contract.MediaType.APPLICATION_JSON; import static io.vertx.openapi.contract.MediaType.APPLICATION_JSON_UTF8; import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.junit5.VertxExtension; import io.vertx.openapi.contract.ContractErrorType; +import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.RequestBody; import io.vertx.openapi.contract.impl.RequestBodyImpl; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.nio.file.Path; +import java.util.Map; +import java.util.Set; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -82,7 +89,7 @@ private static Stream testExceptions() { @MethodSource void testGetters(String testId, boolean required) { JsonObject requestBodyModel = validTestData.getJsonObject(testId); - RequestBodyImpl requestBody = new RequestBodyImpl(requestBodyModel, DUMMY_OPERATION_ID); + RequestBodyImpl requestBody = new RequestBodyImpl(requestBodyModel, DUMMY_OPERATION_ID, emptyMap()); assertThat(requestBody.isRequired()).isEqualTo(required); assertThat(requestBody.getOpenAPIModel()).isEqualTo(requestBodyModel); @@ -95,7 +102,7 @@ void testGetters(String testId, boolean required) { void testExceptions(String testId, ContractErrorType type, String msg) { JsonObject requestBody = invalidTestData.getJsonObject(testId); OpenAPIContractException exception = - assertThrows(OpenAPIContractException.class, () -> new RequestBodyImpl(requestBody, DUMMY_OPERATION_ID)); + assertThrows(OpenAPIContractException.class, () -> new RequestBodyImpl(requestBody, DUMMY_OPERATION_ID, emptyMap())); assertThat(exception.type()).isEqualTo(type); assertThat(exception).hasMessageThat().isEqualTo(msg); } @@ -106,7 +113,7 @@ private RequestBodyImpl buildWithContent(String... contentTypes) { for (String type : contentTypes) { content.put(type, dummySchema); } - return new RequestBodyImpl(new JsonObject().put("content", content), DUMMY_OPERATION_ID); + return new RequestBodyImpl(new JsonObject().put("content", content), DUMMY_OPERATION_ID, emptyMap()); } @Test @@ -130,4 +137,37 @@ void testDetermineContentType() { assertThat(bodyBoth.determineContentType("application/text")).isNull(); } + + @Test + void testCustomMediaType() { + JsonObject requestJson = validTestData.getJsonObject("0003_Test_Custom_MediaType"); + RequestBodyImpl requestBody = new RequestBodyImpl(requestJson, DUMMY_OPERATION_ID, Map.of("text/event-stream", ContentAnalyserFactory.NO_OP)); + Map mediaTypes = requestBody.getContent(); + + assertEquals(2, mediaTypes.size()); + assertEquals(Set.of("application/json", "text/event-stream"), mediaTypes.keySet()); + } + + @Test + void testUnsupportedCustomMediaType() { + JsonObject requestJson = validTestData.getJsonObject("0003_Test_Custom_MediaType"); + + OpenAPIContractException ex = assertThrows( + OpenAPIContractException.class, + () -> new RequestBodyImpl(requestJson, DUMMY_OPERATION_ID, null) + ); + assertTrue(ex.getMessage().contains("Operation " + DUMMY_OPERATION_ID + " defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new RequestBodyImpl(requestJson, DUMMY_OPERATION_ID, emptyMap()) + ); + assertTrue(ex.getMessage().contains("Operation " + DUMMY_OPERATION_ID + " defines a request body with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new RequestBodyImpl(requestJson, DUMMY_OPERATION_ID, Map.of("application/xml", ContentAnalyserFactory.NO_OP)) + ); + assertTrue(ex.getMessage().contains("Operation " + DUMMY_OPERATION_ID + " defines a request body with an unsupported media type")); + } } diff --git a/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java b/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java index 801331c0..0044be48 100644 --- a/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java @@ -18,18 +18,26 @@ import static io.vertx.json.schema.common.dsl.SchemaType.STRING; import static io.vertx.openapi.contract.ContractErrorType.UNSUPPORTED_FEATURE; import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.json.schema.common.dsl.SchemaType; import io.vertx.junit5.VertxExtension; import io.vertx.openapi.contract.ContractErrorType; +import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.impl.ResponseImpl; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import java.nio.file.Path; +import java.util.Map; +import java.util.Set; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -74,7 +82,7 @@ private static Stream testExceptions() { @MethodSource void testGetters(String testId, int contentSize, String contentKey, int headerSize, SchemaType type) { JsonObject responseModel = validTestData.getJsonObject(testId); - ResponseImpl response = new ResponseImpl(responseModel, DUMMY_OPERATION_ID); + ResponseImpl response = new ResponseImpl(responseModel, DUMMY_OPERATION_ID, emptyMap()); assertThat(response.getHeaders()).hasSize(headerSize); if (headerSize > 0) { @@ -94,8 +102,41 @@ void testGetters(String testId, int contentSize, String contentKey, int headerSi void testExceptions(String testId, ContractErrorType type, String msg) { JsonObject response = invalidTestData.getJsonObject(testId); OpenAPIContractException exception = - assertThrows(OpenAPIContractException.class, () -> new ResponseImpl(response, DUMMY_OPERATION_ID)); + assertThrows(OpenAPIContractException.class, () -> new ResponseImpl(response, DUMMY_OPERATION_ID, emptyMap())); assertThat(exception.type()).isEqualTo(type); assertThat(exception).hasMessageThat().isEqualTo(msg); } + + @Test + void testCustomMediaType() { + JsonObject responseJson = validTestData.getJsonObject("0004_Test_Custom_MediaType"); + ResponseImpl response = new ResponseImpl(responseJson, DUMMY_OPERATION_ID, Map.of("text/event-stream", ContentAnalyserFactory.NO_OP)); + Map mediaTypes = response.getContent(); + + assertEquals(2, mediaTypes.size()); + assertEquals(Set.of("application/json", "text/event-stream"), mediaTypes.keySet()); + } + + @Test + void testUnsupportedCustomMediaType() { + JsonObject responseJson = validTestData.getJsonObject("0004_Test_Custom_MediaType"); + + OpenAPIContractException ex = assertThrows( + OpenAPIContractException.class, + () -> new ResponseImpl(responseJson, DUMMY_OPERATION_ID, null) + ); + assertTrue(ex.getMessage().contains("Operation " + DUMMY_OPERATION_ID + " defines a response with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new ResponseImpl(responseJson, DUMMY_OPERATION_ID, emptyMap()) + ); + assertTrue(ex.getMessage().contains("Operation " + DUMMY_OPERATION_ID + " defines a response with an unsupported media type")); + + ex = assertThrows( + OpenAPIContractException.class, + () -> new ResponseImpl(responseJson, DUMMY_OPERATION_ID, Map.of("application/xml", ContentAnalyserFactory.NO_OP)) + ); + assertTrue(ex.getMessage().contains("Operation " + DUMMY_OPERATION_ID + " defines a response with an unsupported media type")); + } } diff --git a/src/test/java/io/vertx/tests/validation/impl/BaseValidatorTest.java b/src/test/java/io/vertx/tests/validation/impl/BaseValidatorTest.java index 8207283c..102540cd 100644 --- a/src/test/java/io/vertx/tests/validation/impl/BaseValidatorTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/BaseValidatorTest.java @@ -15,6 +15,7 @@ import static com.google.common.truth.Truth.assertThat; import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT; import static io.vertx.tests.ResourceHelper.TEST_RESOURCE_PATH; +import static java.util.Collections.emptyMap; import io.vertx.core.Future; import io.vertx.core.Vertx; @@ -83,14 +84,14 @@ static Stream testIsSchemaValidationRequired() { JsonObject binaryStringSchema = stringSchema.copy().put("format", "binary"); Function buildMediaModel = schema -> new JsonObject().put("schema", schema); - MediaType noMediaModel = new MediaTypeImpl("", EMPTY_JSON_OBJECT); - MediaType typeNumber = new MediaTypeImpl("", buildMediaModel.apply(new JsonObject().put("type", "number"))); - MediaType typeStringNoFormat = new MediaTypeImpl("", buildMediaModel.apply(stringSchema)); - MediaType typeStringFormatBinary = new MediaTypeImpl("", buildMediaModel.apply(binaryStringSchema)); + MediaType noMediaModel = new MediaTypeImpl("", EMPTY_JSON_OBJECT, emptyMap()); + MediaType typeNumber = new MediaTypeImpl("", buildMediaModel.apply(new JsonObject().put("type", "number")), emptyMap()); + MediaType typeStringNoFormat = new MediaTypeImpl("", buildMediaModel.apply(stringSchema), emptyMap()); + MediaType typeStringFormatBinary = new MediaTypeImpl("", buildMediaModel.apply(binaryStringSchema), emptyMap()); MediaType typeStringFormatTime = new MediaTypeImpl("", buildMediaModel.apply(stringSchema.copy().put("format", - "time"))); + "time")), emptyMap()); MediaType typeStringFormatBinaryMinLength = new MediaTypeImpl("", - buildMediaModel.apply(binaryStringSchema.copy().put("minLength", 1))); + buildMediaModel.apply(binaryStringSchema.copy().put("minLength", 1)), emptyMap()); return Stream.of( Arguments.of("No media model is defined", noMediaModel, false), diff --git a/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java b/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java index a3931e5e..128c9f84 100644 --- a/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java @@ -47,6 +47,7 @@ import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonObject; import io.vertx.json.schema.JsonSchema; +import io.vertx.json.schema.common.dsl.Keywords; import io.vertx.json.schema.common.dsl.SchemaBuilder; import io.vertx.junit5.Checkpoint; import io.vertx.junit5.Timeout; @@ -59,11 +60,16 @@ import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.RequestBody; import io.vertx.openapi.contract.Style; +import io.vertx.openapi.contract.impl.MediaTypeImpl; +import io.vertx.openapi.impl.Utils; import io.vertx.openapi.validation.RequestParameter; import io.vertx.openapi.validation.RequestValidator; import io.vertx.openapi.validation.ValidatableRequest; import io.vertx.openapi.validation.ValidatedRequest; +import io.vertx.openapi.validation.ValidationContext; import io.vertx.openapi.validation.ValidatorException; +import io.vertx.openapi.validation.analyser.ContentAnalyser; +import io.vertx.openapi.validation.analyser.ContentAnalyserFactory; import io.vertx.openapi.validation.impl.RequestParameterImpl; import io.vertx.openapi.validation.impl.RequestValidatorImpl; import io.vertx.openapi.validation.impl.ValidatableRequestImpl; @@ -84,7 +90,7 @@ import org.junit.jupiter.params.provider.ValueSource; @ExtendWith(VertxExtension.class) -class RequestValidatorImplTest { +public class RequestValidatorImplTest { private RequestValidatorImpl validator; @@ -472,4 +478,50 @@ public void testParameterFormats(String type, JsonObject schema, Object value) { validator.validateParameter(param, new RequestParameterImpl(value)); } + @Test + public void testValidCustomMediaTypeBody() { + JsonObject schema = + new JsonObject() + .put( + "schema", + objectSchema() + .property("a", intSchema()) + .property("b", stringSchema().with(Keywords.minLength(1))) + .toJson()); + var additionalMediaTypes = Map.of("application/yml", new YamlContentAnalyzerFactory()); + + MediaType json = new MediaTypeImpl("application/json", schema, additionalMediaTypes); + MediaType yml = new MediaTypeImpl("application/yml", schema, additionalMediaTypes); + + RequestBody mockedRequestBody = mockRequestBody(true); + when(mockedRequestBody.getContent()).thenReturn(Map.of("application/json", json, "application/yml", yml)); + when(mockedRequestBody.determineContentType("application/yml")).thenReturn(yml); + + String validYaml = "a: 1\nb: abc"; + ValidatableRequest mockedValidatableRequest = mock(ValidatableRequest.class); + when(mockedValidatableRequest.getBody()).thenReturn(new RequestParameterImpl(Buffer.buffer(validYaml))); + when(mockedValidatableRequest.getContentType()).thenReturn("application/yml"); + + RequestParameter param = validator.validateBody(mockedRequestBody, mockedValidatableRequest); + assertThat(param.getJsonObject()).isEqualTo(new JsonObject().put("a", 1).put("b", "abc")); + } + + public static class YamlContentAnalyzerFactory implements ContentAnalyserFactory { + @Override + public ContentAnalyser getContentAnalyser(String contentType, Buffer content, ValidationContext context) { + return new ContentAnalyser(contentType, content, context) { + private JsonObject decoded; + + @Override + public void checkSyntacticalCorrectness() { + decoded = Utils.yamlStringToJson(content.toString()).result(); + } + + @Override + public Object transform() { + return decoded; + } + }; + } + } } diff --git a/src/test/java/io/vertx/tests/validation/impl/ResponseValidatorImplTest.java b/src/test/java/io/vertx/tests/validation/impl/ResponseValidatorImplTest.java index c573abad..95aa297a 100644 --- a/src/test/java/io/vertx/tests/validation/impl/ResponseValidatorImplTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/ResponseValidatorImplTest.java @@ -18,6 +18,7 @@ import static io.vertx.json.schema.common.dsl.Schemas.intSchema; import static io.vertx.json.schema.common.dsl.Schemas.numberSchema; import static io.vertx.json.schema.common.dsl.Schemas.objectSchema; +import static io.vertx.json.schema.common.dsl.Schemas.stringSchema; import static io.vertx.openapi.contract.Location.HEADER; import static io.vertx.openapi.contract.Style.SIMPLE; import static io.vertx.openapi.validation.ValidatorErrorType.INVALID_VALUE; @@ -37,6 +38,7 @@ import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.json.schema.JsonSchema; +import io.vertx.json.schema.common.dsl.Keywords; import io.vertx.json.schema.common.dsl.SchemaBuilder; import io.vertx.junit5.Checkpoint; import io.vertx.junit5.Timeout; @@ -47,6 +49,7 @@ import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.Response; +import io.vertx.openapi.contract.impl.MediaTypeImpl; import io.vertx.openapi.validation.ResponseParameter; import io.vertx.openapi.validation.ValidatableResponse; import io.vertx.openapi.validation.ValidatorException; @@ -270,6 +273,33 @@ void testValidateBodyThrowInvalidValue() { assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); } + @Test + public void testValidCustomMediaTypeBody() { + JsonObject schema = + new JsonObject() + .put( + "schema", + objectSchema() + .property("a", intSchema()) + .property("b", stringSchema().with(Keywords.minLength(1))) + .toJson()); + var additionalMediaTypes = Map.of("application/yml", new RequestValidatorImplTest.YamlContentAnalyzerFactory()); + + MediaType json = new MediaTypeImpl("application/json", schema, additionalMediaTypes); + MediaType yml = new MediaTypeImpl("application/yml", schema, additionalMediaTypes); + + Response mockedResponse = mockResponse(); + when(mockedResponse.getContent()).thenReturn(Map.of("application/json", json, "application/yml", yml)); + + String validYaml = "a: 1\nb: abc"; + ValidatableResponse mockedValidatableResponse = mock(ValidatableResponse.class); + when(mockedValidatableResponse.getBody()).thenReturn(new RequestParameterImpl(Buffer.buffer(validYaml))); + when(mockedValidatableResponse.getContentType()).thenReturn("application/yml"); + + ResponseParameter param = validator.validateBody(mockedResponse, mockedValidatableResponse); + assertThat(param.getJsonObject()).isEqualTo(new JsonObject().put("a", 1).put("b", "abc")); + } + @Test void testValidateBody() { JsonObject body = new JsonObject().put("foo", "bar"); diff --git a/src/test/resources/io/vertx/tests/contract/impl/contract_valid.json b/src/test/resources/io/vertx/tests/contract/impl/contract_valid.json index 661dd659..7215edff 100644 --- a/src/test/resources/io/vertx/tests/contract/impl/contract_valid.json +++ b/src/test/resources/io/vertx/tests/contract/impl/contract_valid.json @@ -447,5 +447,392 @@ } } } + }, + "0002_Test_Custom_Media_Types": { + "contractModel": { + "openapi": "3.1.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "identifier": "MIT", + "name": "MIT License" + } + }, + "servers": [ + { + "url": "https://petstore.swagger.io/v1" + } + ], + "security": [ + { + "BasicAuth": [] + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": [ + "pets" + ], + "requestBody": { + "description": "Create a new pet in the store", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + }, + "application/yml": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "201": { + "description": "Null response" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Pets": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "BasicAuth": { + "scheme": "basic", + "type": "http" + } + } + } + } } } diff --git a/src/test/resources/io/vertx/tests/contract/impl/operation_valid.json b/src/test/resources/io/vertx/tests/contract/impl/operation_valid.json index 98387c55..bfa9f066 100644 --- a/src/test/resources/io/vertx/tests/contract/impl/operation_valid.json +++ b/src/test/resources/io/vertx/tests/contract/impl/operation_valid.json @@ -215,5 +215,49 @@ } } } + }, + "0006_Test_RequestBody_Custom_Media_type": { + "path": "/pets", + "method": "post", + "operationModel": { + "operationId": "createPet", + "tags": [ + "pets", + "foo" + ], + "requestBody": { + "description": "Create a new pet in the store", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "type": "object" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + } + } + } } } diff --git a/src/test/resources/io/vertx/tests/contract/impl/path_valid.json b/src/test/resources/io/vertx/tests/contract/impl/path_valid.json index 5fda5b1f..93be992d 100644 --- a/src/test/resources/io/vertx/tests/contract/impl/path_valid.json +++ b/src/test/resources/io/vertx/tests/contract/impl/path_valid.json @@ -41,5 +41,92 @@ } ] } + }, + "0001_Test_Custom_Media_Type": { + "name": "/pets/{petId}", + "pathModel": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "type": "object" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "summary": "Info for a specific pet", + "operationId": "updatePet", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/xml": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "parameters": [ + { + "name": "petId", + "in": "query", + "schema": { + "type": "string" + } + } + ] + } } } diff --git a/src/test/resources/io/vertx/tests/contract/impl/requestBody_valid.json b/src/test/resources/io/vertx/tests/contract/impl/requestBody_valid.json index 3f2e12ae..089a8f18 100644 --- a/src/test/resources/io/vertx/tests/contract/impl/requestBody_valid.json +++ b/src/test/resources/io/vertx/tests/contract/impl/requestBody_valid.json @@ -27,5 +27,19 @@ } } } + }, + "0003_Test_Custom_MediaType": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + } } } diff --git a/src/test/resources/io/vertx/tests/contract/impl/response_valid.json b/src/test/resources/io/vertx/tests/contract/impl/response_valid.json index 3ddd4918..974d7cdf 100644 --- a/src/test/resources/io/vertx/tests/contract/impl/response_valid.json +++ b/src/test/resources/io/vertx/tests/contract/impl/response_valid.json @@ -46,5 +46,19 @@ } }, "0003_Test_Getters_No_Headers_No_Content": { + }, + "0004_Test_Custom_MediaType": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + } + } + } } }