diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 5657930b..e97dfc87 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -101,15 +101,42 @@ paths: Vert.x OpenAPI checks both whether the content is syntactically correct and whether it corresponds to the schema. If no schema is defined, or the content is binary no schema validation is performed. -Currently, only the following media types are supported: +By Default, the following media types are supported: * application/json * application/json+hal * application/octet-stream * multipart/form-data +* Vendor specific json that matches the following regular expression [^/]+/vnd\.[\w.-]+\+json -NOTE: It is planned to support more media types in the future. -It is also planned to support custom implementations of {@link io.vertx.openapi.validation.analyser.ContentAnalyser}, so that any media type can be validated. +Unknown media types are rejected and the contract will load with an exception. + +You can add additional media types when you construct the contract via the {@link +io.vertx.openapi.contract.OpenAPIContractBuilder}. You need to provide a {@link +io.vertx.openapi.mediatype.MediaTypeRegistry}, either by starting from the default one {@link +io.vertx.openapi.mediatype.MediaTypeRegistry#createDefault} or an empty one {@link +io.vertx.openapi.mediatype.MediaTypeRegistry#createEmpty}. On the registry you can register new media types via a +MediaTypeRegistration that consist of a predicate that checks whether the registration can handle a given media type and +a ContentAnalyserFactory that creates a new ContentAnalyser for each validation. + +Vert.x OpenAPI contains ready to use predicates for static media type strings ({@link +io.vertx.openapi.mediatype.MediaTypePredicate#ofExactTypes}), regular expressions ({@link +io.vertx.openapi.mediatype.MediaTypePredicate#ofRegexp}) and mediatype compatibility ({@link +io.vertx.openapi.mediatype.MediaTypePredicate#ofCompatibleTypes}). If those do not match your need, you must provide +your own predicate implementations. + +Vert.x OpenAPI contains ready to use ContentAnalysers that perform schema validation for json bodies ({@link +io.vertx.openapi.mediatype.ContentAnalyserFactory#json}), multipart bodies ({@link +io.vertx.openapi.mediatype.ContentAnalyserFactory#multipart}). Additionally a noop ContentAnalyser is available that +does not perform any validation ({@link io.vertx.openapi.mediatype.ContentAnalyserFactory#noop}). If those do not fit +your needs, you need to provide your own implementation. + +Example: + +[source,$lang] +---- +{@link examples.ContractExamples#createContractWithCustomMediaTypes} +---- === Validation of Requests diff --git a/src/main/java/examples/ContractExamples.java b/src/main/java/examples/ContractExamples.java index 92f1ca70..695c19bc 100644 --- a/src/main/java/examples/ContractExamples.java +++ b/src/main/java/examples/ContractExamples.java @@ -18,7 +18,10 @@ import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.Path; - +import io.vertx.openapi.mediatype.ContentAnalyserFactory; +import io.vertx.openapi.mediatype.MediaTypeRegistration; +import io.vertx.openapi.mediatype.MediaTypePredicate; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.util.HashMap; import java.util.Map; @@ -61,4 +64,23 @@ public void pathParameterOperationExample() { private OpenAPIContract getContract() { return null; } + + public void createContractWithCustomMediaTypes(Vertx vertx) { + String pathToContract = ".../.../myContract.json"; // json or yaml + String pathToComponents = ".../.../myComponents.json"; // json or yaml + + Future contract = + OpenAPIContract.builder(vertx) + .setContractPath(pathToContract) + .setAdditionalContractPartPaths(Map.of( + "https://example.com/pet-components", pathToComponents)) + .mediaTypeRegistry( + MediaTypeRegistry.createDefault() + .register( + MediaTypeRegistration.create( + MediaTypePredicate.ofExactTypes("text/my-custom-type+json"), + ContentAnalyserFactory.json())) + ) + .build(); + } } diff --git a/src/main/java/io/vertx/openapi/contract/MediaType.java b/src/main/java/io/vertx/openapi/contract/MediaType.java index 3726afdb..d581c6c2 100644 --- a/src/main/java/io/vertx/openapi/contract/MediaType.java +++ b/src/main/java/io/vertx/openapi/contract/MediaType.java @@ -27,20 +27,36 @@ @VertxGen public interface MediaType extends OpenAPIObject { + @Deprecated String APPLICATION_JSON = "application/json"; + @Deprecated String APPLICATION_JSON_UTF8 = APPLICATION_JSON + "; charset=utf-8"; + @Deprecated String MULTIPART_FORM_DATA = "multipart/form-data"; + @Deprecated String APPLICATION_HAL_JSON = "application/hal+json"; + @Deprecated String APPLICATION_OCTET_STREAM = "application/octet-stream"; + @Deprecated String TEXT_PLAIN = "text/plain"; + @Deprecated String TEXT_PLAIN_UTF8 = TEXT_PLAIN + "; charset=utf-8"; + @Deprecated 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); + /** + * @deprecated The {@link io.vertx.openapi.mediatype.MediaTypeRegistry} replaced the usage of this static method. + */ + @Deprecated static boolean isMediaTypeSupported(String type) { return SUPPORTED_MEDIA_TYPES.contains(type.toLowerCase()) || isVendorSpecificJson(type); } + /** + * @deprecated The {@link io.vertx.openapi.mediatype.MediaTypeRegistry} replaced the usage of this static method. + */ + @Deprecated static boolean isVendorSpecificJson(String type) { return VendorSpecificJson.matches(type); } diff --git a/src/main/java/io/vertx/openapi/contract/OpenAPIContract.java b/src/main/java/io/vertx/openapi/contract/OpenAPIContract.java index 75136574..a7d98139 100644 --- a/src/main/java/io/vertx/openapi/contract/OpenAPIContract.java +++ b/src/main/java/io/vertx/openapi/contract/OpenAPIContract.java @@ -12,6 +12,7 @@ package io.vertx.openapi.contract; +import io.vertx.codegen.annotations.GenIgnore; import io.vertx.codegen.annotations.Nullable; import io.vertx.codegen.annotations.VertxGen; import io.vertx.core.Future; @@ -19,6 +20,7 @@ import io.vertx.core.http.HttpMethod; import io.vertx.core.json.JsonObject; import io.vertx.json.schema.SchemaRepository; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.util.List; import java.util.Map; @@ -175,4 +177,12 @@ static Future from(Vertx vertx, JsonObject contract, */ @Nullable SecurityScheme securityScheme(String name); + + /** + * Gets the mediatype registry. + * + * @return The registry. + */ + @GenIgnore + MediaTypeRegistry getMediaTypeRegistry(); } diff --git a/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java b/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java index 0fc05dd7..8bcf8f5b 100644 --- a/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java +++ b/src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java @@ -25,6 +25,7 @@ import io.vertx.json.schema.JsonSchemaValidationException; import io.vertx.openapi.contract.impl.OpenAPIContractImpl; import io.vertx.openapi.impl.Utils; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -57,6 +58,7 @@ public OpenAPIContractBuilderException(String message) { private JsonObject contract; private final Map additionalContractPartPaths = new HashMap<>(); private final Map additionalContractParts = new HashMap<>(); + private MediaTypeRegistry registry; public OpenAPIContractBuilder(Vertx vertx) { this.vertx = vertx; @@ -153,12 +155,19 @@ public OpenAPIContractBuilder setAdditionalContractParts(Map return this; } + public OpenAPIContractBuilder mediaTypeRegistry(MediaTypeRegistry registry) { + this.registry = registry; + return this; + } + /** * Builds the contract. * * @return The contract. */ public Future build() { + if (this.registry == null) + this.registry = MediaTypeRegistry.createDefault(); if (contractPath == null && contract == null) { return Future.failedFuture(new OpenAPIContractBuilderException( @@ -192,7 +201,7 @@ private Future buildOpenAPIContract() { return failedFuture(createInvalidContract(null, e)); } }) - .map(resolvedSpec -> new OpenAPIContractImpl(resolvedSpec, version, repository))) + .map(resolvedSpec -> new OpenAPIContractImpl(resolvedSpec, version, repository, registry))) .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/OpenAPIContractImpl.java b/src/main/java/io/vertx/openapi/contract/impl/OpenAPIContractImpl.java index e696eb7d..a6f91a2d 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.mediatype.MediaTypeRegistry; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -65,13 +66,17 @@ public class OpenAPIContractImpl implements OpenAPIContract { private final Map securitySchemes; + private final MediaTypeRegistry mediaTypes; + // VisibleForTesting final String basePath; - public OpenAPIContractImpl(JsonObject resolvedSpec, OpenAPIVersion version, SchemaRepository schemaRepository) { + public OpenAPIContractImpl(JsonObject resolvedSpec, OpenAPIVersion version, SchemaRepository schemaRepository, + MediaTypeRegistry mediaTypes) { this.rawContract = resolvedSpec; this.version = version; this.schemaRepository = schemaRepository; + this.mediaTypes = mediaTypes; servers = resolvedSpec .getJsonArray(KEY_SERVERS, EMPTY_JSON_ARRAY) @@ -95,7 +100,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, mediaTypes)) .collect(toList()); List sortedPaths = applyMountOrder(unsortedPaths); @@ -232,4 +237,9 @@ public List getSecurityRequirements() { public SecurityScheme securityScheme(String name) { return securitySchemes.get(name); } + + @Override + public MediaTypeRegistry getMediaTypeRegistry() { + return mediaTypes; + } } 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..36e0f815 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.mediatype.MediaTypeRegistry; 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, MediaTypeRegistry registry) { 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, registry); } JsonObject responsesJson = operationModel.getJsonObject(KEY_RESPONSES, EMPTY_JSON_OBJECT); @@ -128,7 +129,7 @@ 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, registry)).orElse(null); responses = unmodifiableMap( responsesJson @@ -137,7 +138,8 @@ 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, registry)))); } @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..4bdbfe7c 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.mediatype.MediaTypeRegistry; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -62,7 +63,8 @@ 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, + MediaTypeRegistry registry) { this.absolutePath = (basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath) + name; this.pathModel = pathModel; if (name.contains("*")) { @@ -82,7 +84,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, registry)) .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..f31f0023 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java @@ -12,8 +12,6 @@ package io.vertx.openapi.contract.impl; -import static io.vertx.openapi.contract.MediaType.SUPPORTED_MEDIA_TYPES; -import static io.vertx.openapi.contract.MediaType.isMediaTypeSupported; import static io.vertx.openapi.contract.OpenAPIContractException.createInvalidContract; import static io.vertx.openapi.contract.OpenAPIContractException.createUnsupportedFeature; import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT; @@ -25,6 +23,7 @@ import io.vertx.json.schema.JsonSchema; import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.RequestBody; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.util.Map; public class RequestBodyImpl implements RequestBody { @@ -37,7 +36,7 @@ public class RequestBodyImpl implements RequestBody { private final Map content; - public RequestBodyImpl(JsonObject requestBodyModel, String operationId) { + public RequestBodyImpl(JsonObject requestBodyModel, String operationId, MediaTypeRegistry registry) { this.requestBodyModel = requestBodyModel; this.required = requestBodyModel.getBoolean(KEY_REQUIRED, false); JsonObject contentObject = requestBodyModel.getJsonObject(KEY_CONTENT, EMPTY_JSON_OBJECT); @@ -48,12 +47,12 @@ public RequestBodyImpl(JsonObject requestBodyModel, String operationId) { .stream() .filter(JsonSchema.EXCLUDE_ANNOTATIONS) .filter(mediaTypeIdentifier -> { - if (isMediaTypeSupported(mediaTypeIdentifier)) { + if (registry.isSupported(mediaTypeIdentifier)) { 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))); + String.format(msgTemplate, operationId, join(", ", registry.supportedTypes()))); }) .collect(toMap(this::removeWhiteSpaces, key -> new MediaTypeImpl(key, contentObject.getJsonObject(key))))); 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..1220567a 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/ResponseImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/ResponseImpl.java @@ -14,8 +14,6 @@ import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; import static io.vertx.openapi.contract.Location.HEADER; -import static io.vertx.openapi.contract.MediaType.SUPPORTED_MEDIA_TYPES; -import static io.vertx.openapi.contract.MediaType.isMediaTypeSupported; import static io.vertx.openapi.contract.OpenAPIContractException.createUnsupportedFeature; import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT; import static java.lang.String.join; @@ -29,6 +27,7 @@ import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.Parameter; import io.vertx.openapi.contract.Response; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.util.List; import java.util.Map; import java.util.function.Predicate; @@ -45,7 +44,7 @@ public class ResponseImpl implements Response { private final JsonObject responseModel; - public ResponseImpl(JsonObject responseModel, String operationId) { + public ResponseImpl(JsonObject responseModel, String operationId, MediaTypeRegistry registry) { this.responseModel = responseModel; JsonObject headersObject = responseModel.getJsonObject(KEY_HEADERS, EMPTY_JSON_OBJECT); @@ -68,9 +67,9 @@ public ResponseImpl(JsonObject responseModel, String operationId) { .filter(JsonSchema.EXCLUDE_ANNOTATIONS) .collect(toMap(identity(), key -> new MediaTypeImpl(key, contentObject.getJsonObject(key))))); - if (content.keySet().stream().anyMatch(type -> !isMediaTypeSupported(type))) { + if (content.keySet().stream().anyMatch(type -> !registry.isSupported(type))) { String msgTemplate = "Operation %s defines a response with an unsupported media type. Supported: %s"; - throw createUnsupportedFeature(String.format(msgTemplate, operationId, join(", ", SUPPORTED_MEDIA_TYPES))); + throw createUnsupportedFeature(String.format(msgTemplate, operationId, join(", ", registry.supportedTypes()))); } } diff --git a/src/main/java/io/vertx/openapi/contract/impl/VendorSpecificJson.java b/src/main/java/io/vertx/openapi/contract/impl/VendorSpecificJson.java index d3f19443..17f9f17e 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/VendorSpecificJson.java +++ b/src/main/java/io/vertx/openapi/contract/impl/VendorSpecificJson.java @@ -17,9 +17,10 @@ /** * Class to expose specific check about a media type being a vendor specific JSON */ +@Deprecated public class VendorSpecificJson { - private static final Pattern VENDOR_SPECIFIC_JSON = Pattern.compile("^[^/]+/vnd\\.[\\w.-]+\\+json$"); + public static final Pattern VENDOR_SPECIFIC_JSON = Pattern.compile("^[^/]+/vnd\\.[\\w.-]+\\+json$"); public static boolean matches(String type) { return type != null && VENDOR_SPECIFIC_JSON.matcher(type).matches(); diff --git a/src/main/java/io/vertx/openapi/mediatype/ContentAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/ContentAnalyser.java new file mode 100644 index 00000000..5b8bcc69 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/ContentAnalyser.java @@ -0,0 +1,36 @@ +package io.vertx.openapi.mediatype; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.openapi.validation.ValidatorException; + +/** + * The content analyser is responsible to check if a request or response has the correct format i.e. is syntactically + * correct, and to transform the content-buffer into a representation that can be validated against a schema. + *

+ * These two methods are intentionally bundled in {@link ContentAnalyser} to prevent some operations from having to + * be performed twice. This is particularly helpful if a library is used that cannot distinguish between these steps. + * In this case, an intermediate result that was generated in {@link #checkSyntacticalCorrectness()}, for example, + * can be reused. + *

+ * Therefore, it is very important to ensure that the {@link #checkSyntacticalCorrectness()} method is always called + * before. + */ +@VertxGen +public interface ContentAnalyser { + /** + * Checks if the content has the expected format i.e. is syntactically correct. + *

+ * Throws a {@link ValidatorException} if the content is syntactically incorrect. + */ + void checkSyntacticalCorrectness() throws ValidatorException; + + /** + * Transforms the content into a format that can be validated by the + * {@link io.vertx.openapi.validation.RequestValidator}, or {@link io.vertx.openapi.validation.ResponseValidator}. + *

+ * Throws a {@link ValidatorException} if the content can't be transformed. + * + * @return the transformed content. + */ + Object transform(); +} diff --git a/src/main/java/io/vertx/openapi/mediatype/ContentAnalyserFactory.java b/src/main/java/io/vertx/openapi/mediatype/ContentAnalyserFactory.java new file mode 100644 index 00000000..7db5f291 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/ContentAnalyserFactory.java @@ -0,0 +1,34 @@ +package io.vertx.openapi.mediatype; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.mediatype.impl.ApplicationJsonAnalyser; +import io.vertx.openapi.mediatype.impl.MultipartFormAnalyser; +import io.vertx.openapi.mediatype.impl.NoOpAnalyser; +import io.vertx.openapi.validation.ValidationContext; + +@VertxGen +public interface ContentAnalyserFactory { + + /** + * Creates a new ContentAnalyser for the provided request. + * + * @param contentType The raw content type from the http headers. + * @param content The content of the request or response. + * @param context Whether the analyser is for a request or response. + * @return A fresh content analyser instance. + */ + ContentAnalyser createContentAnalyser(String contentType, Buffer content, ValidationContext context); + + static ContentAnalyserFactory json() { + return ApplicationJsonAnalyser::new; + } + + static ContentAnalyserFactory noop() { + return NoOpAnalyser::new; + } + + static ContentAnalyserFactory multipart() { + return MultipartFormAnalyser::new; + } +} diff --git a/src/main/java/io/vertx/openapi/mediatype/MediaTypeException.java b/src/main/java/io/vertx/openapi/mediatype/MediaTypeException.java new file mode 100644 index 00000000..0e8c9ede --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/MediaTypeException.java @@ -0,0 +1,8 @@ +package io.vertx.openapi.mediatype; + +public class MediaTypeException extends RuntimeException { + public MediaTypeException(String message) { + super(message); + } + +} diff --git a/src/main/java/io/vertx/openapi/mediatype/MediaTypeInfo.java b/src/main/java/io/vertx/openapi/mediatype/MediaTypeInfo.java new file mode 100644 index 00000000..2d4ca17e --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/MediaTypeInfo.java @@ -0,0 +1,160 @@ +package io.vertx.openapi.mediatype; + +import io.vertx.codegen.annotations.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Represents a media type string and provides simple access to the type, the suffix and the parameters. + */ +public class MediaTypeInfo { + + private final String type; + private final String subtype; + private final String suffix; + private final Map parameters; + + public MediaTypeInfo(String type, String subtype, @Nullable String suffix, Map parameters) { + this.type = type; + this.subtype = subtype; + this.suffix = suffix; + this.parameters = parameters; + } + + public static MediaTypeInfo of(String mediaType) { + var type = new StringBuilder(); + var subtype = new StringBuilder(); + var suffix = new StringBuilder(); + + var parameters = new LinkedHashMap(); + var paramKey = new StringBuilder(); + var paramValue = new StringBuilder(); + + var mode = 0; // 0: type, 1: subtype, 2: suffix, 3: parameter key, 4: parameter value + for (int i = 0; i < mediaType.length(); i++) { + var c = mediaType.charAt(i); + if (c == '/') + mode = 1; + else if (c == '+') + mode = 2; + else if (c == ';') { + mode = 3; + if (paramKey.length() > 0) { + parameters.put(paramKey.toString().trim(), paramValue.toString().trim()); + paramKey = new StringBuilder(); + paramValue = new StringBuilder(); + } + } else if (c == '=') + mode = 4; + else + switch (mode) { + case 0: + type.append(c); + break; + case 1: + subtype.append(c); + break; + case 2: + suffix.append(c); + break; + case 3: + paramKey.append(c); + break; + case 4: + paramValue.append(c); + break; + default: + throw new IllegalStateException("Should not happen"); + } + } + if (paramKey.length() > 0) { + parameters.put(paramKey.toString().trim(), paramValue.toString().trim()); + } + return new MediaTypeInfo( + type.toString(), + subtype.toString(), + suffix.length() == 0 ? null : suffix.toString().trim(), + parameters); + } + + public String type() { + return type; + } + + public String subtype() { + return subtype; + } + + /** + * Get the type including suffix if available. + * + * @return type including suffix if available + */ + public String fullType() { + var sb = new StringBuilder(type).append("/").append(subtype); + suffix().ifPresent(s -> sb.append("+").append(s)); + return sb.toString(); + } + + public Map parameters() { + return parameters; + } + + public Optional suffix() { + return Optional.ofNullable(suffix); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + MediaTypeInfo mediaType = (MediaTypeInfo) o; + return Objects.equals(type, mediaType.type) && Objects.equals(suffix, mediaType.suffix) + && Objects.equals(parameters, mediaType.parameters); + } + + @Override + public int hashCode() { + return Objects.hash(type, suffix, parameters); + } + + @Override + public String toString() { + var sb = new StringBuilder(fullType()); + if (!parameters.isEmpty()) { + for (var e : parameters.entrySet()) { + sb.append("; ").append(e.getKey()).append("=").append(e.getValue()); + } + } + return sb.toString(); + } + + /** + * Checks if the other mediatype is compatible to this mediatype. The other mediatype is compatible when it is equal + * or more specific than this mediatype, e.g. application/vnd.example+json is more specific than application/vnd.example + * and thus compatible to it, whereas the other way around they are not compatible. + * + * @param other The other mediatype + * @return true if it is compatible, false otherwise + */ + public boolean doesInclude(MediaTypeInfo other) { + if (this.type.equals(other.type)) { + if (this.subtype.equals("*") || this.subtype.equals(other.subtype)) { + if (this.suffix == null || Objects.equals(this.suffix, other.suffix)) { + for (var e : this.parameters.entrySet()) { + if (!(other.parameters.containsKey(e.getKey()) + && Objects.equals(e.getValue(), other.parameters.get(e.getKey())))) { + // does not include the same parameter, so we fail + return false; + } + } + // every check was okay so far. They are compatible + return true; + } + } + } + return false; + } +} diff --git a/src/main/java/io/vertx/openapi/mediatype/MediaTypePredicate.java b/src/main/java/io/vertx/openapi/mediatype/MediaTypePredicate.java new file mode 100644 index 00000000..2875b0c8 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/MediaTypePredicate.java @@ -0,0 +1,86 @@ +package io.vertx.openapi.mediatype; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * A predicate that + */ +public interface MediaTypePredicate extends Predicate { + + /** + * This method is intended for reporting of supported media types in the system. + * + * @return The list of supported types. + */ + List supportedTypes(); + + /** + * Factory for a predicate that checks if the provided mediatype is compatible to any of the types. + * + * @param types The types to accept + * @return The predicate that checks if the mediatype is compatible to one of the types. + */ + static MediaTypePredicate ofCompatibleTypes(String... types) { + var list = Arrays.stream(types).map(MediaTypeInfo::of).collect(Collectors.toList()); + return new MediaTypePredicate() { + @Override + public List supportedTypes() { + return list.stream().map(Object::toString).collect(Collectors.toList()); + } + + @Override + public boolean test(MediaTypeInfo s) { + return list.stream().anyMatch(x -> x.doesInclude(s)); + } + }; + } + + /** + * Factory for a predicate that accepts a list of types. Checks if the mediatype is equal to one of the types + * provided. Only checks the type/subtype+suffix. Ignores the attributes. + * + * @param types The types to accept + * @return The predicate that checks if the mediatype is part of the provided list. + */ + static MediaTypePredicate ofExactTypes(String... types) { + var list = List.of(types); + return new MediaTypePredicate() { + @Override + public List supportedTypes() { + return list; + } + + @Override + public boolean test(MediaTypeInfo s) { + return list.stream().anyMatch(x -> x.equals(s.fullType())); + } + }; + } + + /** + * Factory for a predicate that accepts types based on a regular expression. Only checks the type/subtype+suffix. + * Ignores the attributes. + * + * @param regexp The regular expression + * @return A predicate that checks if the mediatype matches the regular expression. + */ + static MediaTypePredicate ofRegexp(String regexp) { + var pattern = Pattern.compile(regexp); + + return new MediaTypePredicate() { + @Override + public List supportedTypes() { + return List.of(regexp); + } + + @Override + public boolean test(MediaTypeInfo s) { + return pattern.matcher(s.fullType()).matches(); + } + }; + } +} diff --git a/src/main/java/io/vertx/openapi/mediatype/MediaTypeRegistration.java b/src/main/java/io/vertx/openapi/mediatype/MediaTypeRegistration.java new file mode 100644 index 00000000..6555a503 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/MediaTypeRegistration.java @@ -0,0 +1,96 @@ +package io.vertx.openapi.mediatype; + +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.contract.MediaType; +import io.vertx.openapi.contract.impl.VendorSpecificJson; +import io.vertx.openapi.mediatype.impl.NoOpAnalyser; +import io.vertx.openapi.validation.ValidationContext; +import java.util.List; + +/** + * A MediaTypeRegistration is used to register mediatypes to the openapi mediatype registry. It consists of a predicate + * that checks if a media type can be handled by the created content analysers. + */ +public interface MediaTypeRegistration extends ContentAnalyserFactory { + + MediaTypeRegistration APPLICATION_JSON = + create( + MediaTypePredicate.ofExactTypes( + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_JSON_UTF8, + MediaType.APPLICATION_HAL_JSON), + ContentAnalyserFactory.json()); + MediaTypeRegistration MULTIPART_FORM_DATA = + create( + MediaTypePredicate.ofExactTypes(MediaType.MULTIPART_FORM_DATA), + ContentAnalyserFactory.multipart()); + MediaTypeRegistration TEXT_PLAIN = + alwaysValid( + MediaTypePredicate.ofExactTypes( + MediaType.TEXT_PLAIN, + MediaType.TEXT_PLAIN_UTF8)); + MediaTypeRegistration APPLICATION_OCTET_STREAM = + alwaysValid( + MediaTypePredicate.ofExactTypes(MediaType.APPLICATION_OCTET_STREAM)); + MediaTypeRegistration VENDOR_SPECIFIC_JSON = + alwaysValid( + MediaTypePredicate.ofRegexp(VendorSpecificJson.VENDOR_SPECIFIC_JSON.pattern())); + + /** + * Creates a new registration from the provided predicate and ContentAnalyserFactory. + * + * @param canHandleMediaType A predicate to check if the mediatype can be handled + * @param factory A factory for content analysers + * @return The registration object + */ + static MediaTypeRegistration create( + MediaTypePredicate canHandleMediaType, + ContentAnalyserFactory factory) { + return new MediaTypeRegistration() { + + @Override + public List supportedTypes() { + return canHandleMediaType.supportedTypes(); + } + + @Override + public ContentAnalyser createContentAnalyser(String contentType, Buffer content, ValidationContext context) { + return factory.createContentAnalyser(contentType, content, context); + } + + @Override + public boolean canHandle(MediaTypeInfo mediaType) { + return canHandleMediaType.test(mediaType); + } + + }; + } + + /** + * Creates a registration that does simply return the input without any validation. Can be used to register + * mediatypes that do not require validation. + * + * @param canHandleMediaType A predicate to check if the mediatype can be handled + * @return A registration object. + */ + static MediaTypeRegistration alwaysValid(MediaTypePredicate canHandleMediaType) { + return create(canHandleMediaType, NoOpAnalyser::new); + } + + /** + * Checks if this registration can handle the given media type. This method is intended to be used by the + * MediaTypeRegistry. + * + * @param mediaType The media type to check + * @return true if the mediatype can be handled, false otherwise + */ + boolean canHandle(MediaTypeInfo mediaType); + + /** + * This method is intended for reporting of supported media types in the system. + * + * @return The list of supported types. + */ + List supportedTypes(); + +} diff --git a/src/main/java/io/vertx/openapi/mediatype/MediaTypeRegistry.java b/src/main/java/io/vertx/openapi/mediatype/MediaTypeRegistry.java new file mode 100644 index 00000000..8ef7429e --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/MediaTypeRegistry.java @@ -0,0 +1,66 @@ +package io.vertx.openapi.mediatype; + +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.mediatype.impl.DefaultMediaTypeRegistry; +import io.vertx.openapi.validation.ValidationContext; +import java.util.List; + +/** + * The MediaTypeRegistry contains all supported MediaTypes and Validators for the mediatypes. New MediaTypes can be registered + * by providing new MediaTypeRegistrations. + */ +public interface MediaTypeRegistry { + /** + * Creates a default registry with application/json, application/multipart and text/plain mediatypes registered. + * + * @return A registry with default options. + */ + static MediaTypeRegistry createDefault() { + return new DefaultMediaTypeRegistry() + .register(MediaTypeRegistration.APPLICATION_JSON) + .register(MediaTypeRegistration.MULTIPART_FORM_DATA) + .register(MediaTypeRegistration.APPLICATION_OCTET_STREAM) + .register(MediaTypeRegistration.TEXT_PLAIN) + .register(MediaTypeRegistration.VENDOR_SPECIFIC_JSON); + } + + /** + * Creates an empty registry. + * + * @return An empty registry. + */ + static MediaTypeRegistry createEmpty() { + return new DefaultMediaTypeRegistry(); + } + + /** + * Registers a new MediaTypeHandler + * + * @param registration The mediatype registration. + * @return This registry for a fluent interface. + */ + MediaTypeRegistry register(MediaTypeRegistration registration); + + /** + * Checks if the provided media type is supported by the registration + * + * @param type The raw mediatype string + * @return true if it supported, false otherwise + */ + boolean isSupported(String type); + + /** + * @return A list of all supported types. + */ + List supportedTypes(); + + /** + * Creates a new ContentAnalyser for the provided request. + * + * @param contentType The raw content type from the http headers. + * @param content The content of the request or response. + * @param context Whether the analyser is for a request or response. + * @return A fresh content analyser instance. + */ + ContentAnalyser createContentAnalyser(String contentType, Buffer content, ValidationContext context); +} diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/AbstractContentAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/impl/AbstractContentAnalyser.java new file mode 100644 index 00000000..406aa030 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/impl/AbstractContentAnalyser.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024, SAP SE + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + */ + +package io.vertx.openapi.mediatype.impl; + +import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; + +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.DecodeException; +import io.vertx.core.json.Json; +import io.vertx.openapi.mediatype.ContentAnalyser; +import io.vertx.openapi.validation.ValidationContext; +import io.vertx.openapi.validation.ValidatorException; + +/** + * Base class for your own analysers. Provides access to some useful helper methods. + */ +public abstract class AbstractContentAnalyser implements ContentAnalyser { + + protected final String contentType; + protected final Buffer content; + protected final ValidationContext requestOrResponse; + + /** + * 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 AbstractContentAnalyser(String contentType, Buffer content, ValidationContext context) { + this.contentType = contentType; + this.content = content; + this.requestOrResponse = context; + } + + /** + * Builds a {@link ValidatorException} for the case that the content is syntactically incorrect. + * + * @param message the error message. + * @return the {@link ValidatorException}. + */ + protected static ValidatorException buildSyntaxException(String message) { + return new ValidatorException(message, ILLEGAL_VALUE); + } + + /** + * Decodes the passed content as JSON. + * + * @return an object representing the passed JSON content. + * @throws ValidatorException if the content can't be decoded. + */ + protected static Object decodeJsonContent(Buffer content, ValidationContext requestOrResponse) { + try { + return Json.decodeValue(content); + } catch (DecodeException e) { + throw buildSyntaxException("The " + requestOrResponse + " body can't be decoded"); + } + } +} diff --git a/src/main/java/io/vertx/openapi/validation/analyser/ApplicationJsonAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/impl/ApplicationJsonAnalyser.java similarity index 54% rename from src/main/java/io/vertx/openapi/validation/analyser/ApplicationJsonAnalyser.java rename to src/main/java/io/vertx/openapi/mediatype/impl/ApplicationJsonAnalyser.java index d2f47a3f..82fbb369 100644 --- a/src/main/java/io/vertx/openapi/validation/analyser/ApplicationJsonAnalyser.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/ApplicationJsonAnalyser.java @@ -10,12 +10,15 @@ * */ -package io.vertx.openapi.validation.analyser; +package io.vertx.openapi.mediatype.impl; import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.DecodeException; +import io.vertx.core.json.Json; import io.vertx.openapi.validation.ValidationContext; +import io.vertx.openapi.validation.ValidatorException; -public class ApplicationJsonAnalyser extends ContentAnalyser { +public class ApplicationJsonAnalyser extends AbstractContentAnalyser { private Object decodedValue; public ApplicationJsonAnalyser(String contentType, Buffer content, ValidationContext context) { @@ -31,4 +34,18 @@ public void checkSyntacticalCorrectness() { public Object transform() { return decodedValue; } + + /** + * Decodes the passed content as JSON. + * + * @return an object representing the passed JSON content. + * @throws ValidatorException if the content can't be decoded. + */ + protected static Object decodeJsonContent(Buffer content, ValidationContext requestOrResponse) { + try { + return Json.decodeValue(content); + } catch (DecodeException e) { + throw buildSyntaxException("The " + requestOrResponse + " body can't be decoded"); + } + } } diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/DefaultMediaTypeRegistry.java b/src/main/java/io/vertx/openapi/mediatype/impl/DefaultMediaTypeRegistry.java new file mode 100644 index 00000000..123946c4 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/impl/DefaultMediaTypeRegistry.java @@ -0,0 +1,46 @@ +package io.vertx.openapi.mediatype.impl; + +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.mediatype.ContentAnalyser; +import io.vertx.openapi.mediatype.MediaTypeException; +import io.vertx.openapi.mediatype.MediaTypeInfo; +import io.vertx.openapi.mediatype.MediaTypeRegistration; +import io.vertx.openapi.mediatype.MediaTypeRegistry; +import io.vertx.openapi.validation.ValidationContext; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Default implementation for MediaTypeRegistry + */ +public class DefaultMediaTypeRegistry implements MediaTypeRegistry { + private final List registrations = new ArrayList<>(); + + @Override + public DefaultMediaTypeRegistry register(MediaTypeRegistration registration) { + this.registrations.add(registration); + return this; + } + + @Override + public boolean isSupported(String type) { + var v = MediaTypeInfo.of(type); + return this.registrations.stream().anyMatch(x -> x.canHandle(v)); + } + + @Override + public ContentAnalyser createContentAnalyser(String contentType, Buffer content, ValidationContext context) { + var v = MediaTypeInfo.of(contentType); + var reg = this.registrations.stream().filter(x -> x.canHandle(v)).findFirst(); + if (reg.isEmpty()) { + throw new MediaTypeException("Unsupported media type " + contentType); + } + return reg.get().createContentAnalyser(contentType, content, context); + } + + @Override + public List supportedTypes() { + return registrations.stream().flatMap(x -> x.supportedTypes().stream()).collect(Collectors.toList()); + } +} diff --git a/src/main/java/io/vertx/openapi/validation/analyser/MultipartFormAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java similarity index 97% rename from src/main/java/io/vertx/openapi/validation/analyser/MultipartFormAnalyser.java rename to src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java index b2874908..b3f2731c 100644 --- a/src/main/java/io/vertx/openapi/validation/analyser/MultipartFormAnalyser.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartFormAnalyser.java @@ -10,7 +10,7 @@ * */ -package io.vertx.openapi.validation.analyser; +package io.vertx.openapi.mediatype.impl; import static io.netty.handler.codec.http.HttpHeaderValues.MULTIPART_FORM_DATA; import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; @@ -24,7 +24,7 @@ import io.vertx.openapi.validation.ValidatorException; import java.util.List; -public class MultipartFormAnalyser extends ContentAnalyser { +public class MultipartFormAnalyser extends AbstractContentAnalyser { private static final String BOUNDARY = "boundary="; private List parts; diff --git a/src/main/java/io/vertx/openapi/validation/analyser/MultipartPart.java b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java similarity index 98% rename from src/main/java/io/vertx/openapi/validation/analyser/MultipartPart.java rename to src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java index 32c96fde..73322eee 100644 --- a/src/main/java/io/vertx/openapi/validation/analyser/MultipartPart.java +++ b/src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java @@ -10,7 +10,7 @@ * */ -package io.vertx.openapi.validation.analyser; +package io.vertx.openapi.mediatype.impl; import static io.vertx.openapi.validation.ValidatorErrorType.INVALID_VALUE; import static java.util.regex.Pattern.CASE_INSENSITIVE; diff --git a/src/main/java/io/vertx/openapi/mediatype/impl/NoOpAnalyser.java b/src/main/java/io/vertx/openapi/mediatype/impl/NoOpAnalyser.java new file mode 100644 index 00000000..5e07e848 --- /dev/null +++ b/src/main/java/io/vertx/openapi/mediatype/impl/NoOpAnalyser.java @@ -0,0 +1,20 @@ +package io.vertx.openapi.mediatype.impl; + +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.validation.ValidationContext; + +public class NoOpAnalyser extends AbstractContentAnalyser { + public NoOpAnalyser(String contentType, Buffer content, ValidationContext context) { + super(contentType, content, context); + } + + @Override + public void checkSyntacticalCorrectness() { + // no syntax check + } + + @Override + public Object transform() { + return content; + } +} diff --git a/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java deleted file mode 100644 index 60781d2d..00000000 --- a/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2024, SAP SE - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 - * which is available at https://www.apache.org/licenses/LICENSE-2.0. - * - * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 - * - */ - -package io.vertx.openapi.validation.analyser; - -import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; - -import io.vertx.core.buffer.Buffer; -import io.vertx.core.json.DecodeException; -import io.vertx.core.json.Json; -import io.vertx.openapi.contract.MediaType; -import io.vertx.openapi.validation.ValidationContext; -import io.vertx.openapi.validation.ValidatorException; - -/** - * The content analyser is responsible for checking if the content is syntactically correct, and transforming the - * content. - *

- * These two methods are intentionally bundled in {@link ContentAnalyser} to prevent some operations from having to - * be performed twice. This is particularly helpful if a library is used that cannot distinguish between these steps. - * In this case, an intermediate result that was generated in {@link #checkSyntacticalCorrectness()}, for example, - * can be reused. - *

- * Therefore, it is very important to ensure that the {@link #checkSyntacticalCorrectness()} method is always called - * before. - */ -public abstract class ContentAnalyser { - private static class NoOpAnalyser extends ContentAnalyser { - public NoOpAnalyser(String contentType, Buffer content, ValidationContext context) { - super(contentType, content, context); - } - - @Override - public void checkSyntacticalCorrectness() { - // no syntax check - } - - @Override - public Object transform() { - return content; - } - } - - /** - * Returns the content analyser for the given content type. - * - * @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) { - switch (mediaType.getIdentifier()) { - case MediaType.APPLICATION_JSON: - case MediaType.APPLICATION_JSON_UTF8: - case MediaType.APPLICATION_HAL_JSON: - return new ApplicationJsonAnalyser(contentType, content, context); - case MediaType.MULTIPART_FORM_DATA: - return new MultipartFormAnalyser(contentType, content, context); - case MediaType.APPLICATION_OCTET_STREAM: - case MediaType.TEXT_PLAIN: - case MediaType.TEXT_PLAIN_UTF8: - return new NoOpAnalyser(contentType, content, context); - default: - if (MediaType.isVendorSpecificJson(contentType)) { - return new ApplicationJsonAnalyser(contentType, content, context); - } - return null; - } - } - - protected final String contentType; - protected final Buffer content; - protected final ValidationContext requestOrResponse; - - /** - * 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 ContentAnalyser(String contentType, Buffer content, ValidationContext context) { - this.contentType = contentType; - this.content = content; - this.requestOrResponse = context; - } - - /** - * Checks if the content is syntactically correct. - *

- * Throws a {@link ValidatorException} if the content is syntactically incorrect. - */ - public abstract void checkSyntacticalCorrectness(); - - /** - * Transforms the content into a format that can be validated by the - * {@link io.vertx.openapi.validation.RequestValidator}, or {@link io.vertx.openapi.validation.ResponseValidator}. - *

- * Throws a {@link ValidatorException} if the content can't be transformed. - * - * @return the transformed content. - */ - public abstract Object transform(); - - /** - * Builds a {@link ValidatorException} for the case that the content is syntactically incorrect. - * - * @param message the error message. - * @return the {@link ValidatorException}. - */ - protected static ValidatorException buildSyntaxException(String message) { - return new ValidatorException(message, ILLEGAL_VALUE); - } - - /** - * Decodes the passed content as JSON. - * - * @return an object representing the passed JSON content. - * @throws ValidatorException if the content can't be decoded. - */ - protected static Object decodeJsonContent(Buffer content, ValidationContext requestOrResponse) { - try { - return Json.decodeValue(content); - } catch (DecodeException e) { - throw buildSyntaxException("The " + requestOrResponse + " body can't be decoded"); - } - } -} diff --git a/src/main/java/io/vertx/openapi/validation/impl/BaseValidator.java b/src/main/java/io/vertx/openapi/validation/impl/BaseValidator.java index b9bdfa1c..7abf9a38 100644 --- a/src/main/java/io/vertx/openapi/validation/impl/BaseValidator.java +++ b/src/main/java/io/vertx/openapi/validation/impl/BaseValidator.java @@ -26,9 +26,9 @@ import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContract; import io.vertx.openapi.contract.Operation; +import io.vertx.openapi.mediatype.ContentAnalyser; import io.vertx.openapi.validation.ValidationContext; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.analyser.ContentAnalyser; public class BaseValidator { protected final Vertx vertx; @@ -67,14 +67,14 @@ protected boolean isSchemaValidationRequired(MediaType mediaType) { protected RequestParameterImpl validate(MediaType mediaType, String contentType, Buffer rawContent, ValidationContext requestOrResponse) { - ContentAnalyser contentAnalyser = mediaType == null ? null - : ContentAnalyser.getContentAnalyser(mediaType, contentType, rawContent, requestOrResponse); - - if (contentAnalyser == null) { + if (!contract.getMediaTypeRegistry().isSupported(contentType) || mediaType == null) { throw new ValidatorException("The format of the " + requestOrResponse + " body is not supported", UNSUPPORTED_VALUE_FORMAT); } + ContentAnalyser contentAnalyser = + contract.getMediaTypeRegistry().createContentAnalyser(contentType, rawContent, requestOrResponse); + // Throws an exception if the content is not syntactically correct contentAnalyser.checkSyntacticalCorrectness(); diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index e35fe18e..52c1143d 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -13,11 +13,13 @@ exports io.vertx.openapi.contract; exports io.vertx.openapi.validation; exports io.vertx.openapi.validation.transformer; + exports io.vertx.openapi.mediatype; exports io.vertx.openapi.impl to io.vertx.tests; exports io.vertx.openapi.validation.impl to io.vertx.tests; - exports io.vertx.openapi.validation.analyser to io.vertx.tests; exports io.vertx.openapi.contract.impl to io.vertx.tests; opens io.vertx.openapi.validation.impl to io.vertx.tests; + exports io.vertx.openapi.mediatype.impl; + } diff --git a/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java b/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java index 8d0122ed..3b6bc380 100644 --- a/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java +++ b/src/test/java/io/vertx/tests/contract/OpenAPIContractBuilderTest.java @@ -22,6 +22,10 @@ import io.vertx.openapi.contract.OpenAPIContractBuilder; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.impl.Utils; +import io.vertx.openapi.mediatype.ContentAnalyserFactory; +import io.vertx.openapi.mediatype.MediaTypePredicate; +import io.vertx.openapi.mediatype.MediaTypeRegistration; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import io.vertx.tests.ResourceHelper; import java.nio.file.Path; import java.nio.file.Paths; @@ -235,4 +239,24 @@ void set_additional_contract_paths_should_replace_existing_contract_paths(Vertx } } + @Test + void registering_custom_mediatypes_should_succeed(Vertx vertx, VertxTestContext ctx) { + var path = getRelatedTestResourcePath(TestSetupOfAdditionalContractParts.class).resolve("builder") + .resolve("contract-with-unsupported-mediatype.yaml").toString(); + OpenAPIContract.builder(vertx) + .setContractPath(path) + .mediaTypeRegistry(MediaTypeRegistry.createDefault() + .register( + MediaTypeRegistration.create( + MediaTypePredicate.ofExactTypes("text/tab-separated-values"), + ContentAnalyserFactory.json())) + .register( + MediaTypeRegistration.create( + MediaTypePredicate + .ofExactTypes("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + ContentAnalyserFactory.json()))) + .build() + .onComplete(ctx.succeedingThenComplete()); + } + } 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..56f45655 100644 --- a/src/test/java/io/vertx/tests/contract/impl/OpenAPIContractImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/OpenAPIContractImplTest.java @@ -29,6 +29,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.mediatype.MediaTypeRegistry; import io.vertx.tests.ResourceHelper; import java.io.IOException; import java.nio.file.Files; @@ -53,32 +54,33 @@ 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(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/{abc}/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/{abc}/{foo}/bar", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/v1/docs/docId", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/pets/petId", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/v1/docs/{docId}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault())); 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(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/v1/docs/docId", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/v2", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/v1/docs/{docId}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/{abc}/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/{abc}/{foo}/bar", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault())); 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(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/pets/{petId}", EMPTY_JSON_OBJECT, emptyList(), + MediaTypeRegistry.createDefault())), "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(), MediaTypeRegistry.createDefault()), + new PathImpl(BASE_PATH, "/pets/{foo}", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault())), "Found Paths with same hierarchy but different templated names: /pets/{}")); } @@ -108,7 +110,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 +122,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 +135,8 @@ 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, MediaTypeRegistry.createDefault()); assertThat(contract.getServers()).hasSize(1); assertThat(contract.getServers().get(0).getURL()).isEqualTo("https://petstore.swagger.io/v1"); @@ -173,6 +176,6 @@ private static OpenAPIContractImpl fromTestData(String testId) throws IOExceptio 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, MediaTypeRegistry.createDefault()); } } 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..570e14e6 100644 --- a/src/test/java/io/vertx/tests/contract/impl/OperationImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/OperationImplTest.java @@ -37,6 +37,7 @@ import io.vertx.openapi.contract.RequestBody; import io.vertx.openapi.contract.impl.OperationImpl; import io.vertx.openapi.contract.impl.SecurityRequirementImpl; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import io.vertx.tests.ResourceHelper; import java.nio.file.Path; import java.util.Arrays; @@ -85,7 +86,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), MediaTypeRegistry.createDefault()); } @ParameterizedTest(name = "{index} should throw an exception for scenario: {0}") @@ -175,7 +176,8 @@ 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(), + MediaTypeRegistry.createDefault()); assertThat(op.getExtensions()).containsExactlyEntriesIn(expected); } 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..6472d7df 100644 --- a/src/test/java/io/vertx/tests/contract/impl/PathImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/PathImplTest.java @@ -24,6 +24,7 @@ import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.Operation; import io.vertx.openapi.contract.impl.PathImpl; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import io.vertx.tests.ResourceHelper; import java.nio.file.Path; import org.junit.jupiter.api.BeforeAll; @@ -49,7 +50,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(), MediaTypeRegistry.createDefault()); } @Test @@ -74,7 +75,7 @@ void testGetters() { void testWildcardInPath() { OpenAPIContractException exception = assertThrows(OpenAPIContractException.class, () -> new PathImpl(BASE_PATH, "/pets/*", EMPTY_JSON_OBJECT, - emptyList())); + emptyList(), MediaTypeRegistry.createDefault())); String expectedMsg = "The passed OpenAPI contract is invalid: Paths must not have a wildcard (asterisk): /pets/*"; assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); } @@ -85,7 +86,7 @@ void testWildcardInPath() { 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(), MediaTypeRegistry.createDefault())); String expectedMsg = "The passed OpenAPI contract is invalid: Curly brace MUST be the first/last character in a path segment " + "(/{parameterName}/): " + path; @@ -101,19 +102,25 @@ 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(), MediaTypeRegistry.createDefault()) + .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(), MediaTypeRegistry.createDefault()).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(), MediaTypeRegistry.createDefault()) + .getAbsolutePath()).isEqualTo(expected); + assertThat(new PathImpl("/base/", "/foo", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault()) + .getAbsolutePath()).isEqualTo(expected); } } 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..c3ae4c4f 100644 --- a/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java @@ -27,6 +27,7 @@ import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.RequestBody; import io.vertx.openapi.contract.impl.RequestBodyImpl; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.nio.file.Path; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; @@ -75,14 +76,15 @@ private static Stream testExceptions() { Arguments.of("0002_RequestBody_With_Content_Type_Application_Png", UNSUPPORTED_FEATURE, "The passed OpenAPI contract contains a feature that is not supported: Operation dummyOperation defines a " + "request body with an unsupported media type. Supported: application/json, application/json; charset=utf-8," - + " multipart/form-data, application/hal+json, application/octet-stream, text/plain, text/plain; charset=utf-8")); + + " application/hal+json, multipart/form-data, application/octet-stream, text/plain, text/plain; charset=utf-8, ^[^/]+/vnd\\.[\\w.-]+\\+json$")); } @ParameterizedTest(name = "{index} test getters for scenario: {0}") @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, MediaTypeRegistry.createDefault()); assertThat(requestBody.isRequired()).isEqualTo(required); assertThat(requestBody.getOpenAPIModel()).isEqualTo(requestBodyModel); @@ -95,7 +97,8 @@ 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, MediaTypeRegistry.createDefault())); assertThat(exception.type()).isEqualTo(type); assertThat(exception).hasMessageThat().isEqualTo(msg); } @@ -106,7 +109,8 @@ 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, + MediaTypeRegistry.createDefault()); } @Test 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..37b9ab61 100644 --- a/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java @@ -27,6 +27,7 @@ import io.vertx.openapi.contract.ContractErrorType; import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.impl.ResponseImpl; +import io.vertx.openapi.mediatype.MediaTypeRegistry; import java.nio.file.Path; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; @@ -67,14 +68,14 @@ private static Stream testExceptions() { Arguments.of("0000_Response_With_Content_Type_Application_Png", UNSUPPORTED_FEATURE, "The passed OpenAPI contract contains a feature that is not supported: Operation dummyOperation defines a " + "response with an unsupported media type. Supported: application/json, application/json; charset=utf-8, " - + "multipart/form-data, application/hal+json, application/octet-stream, text/plain, text/plain; charset=utf-8")); + + "application/hal+json, multipart/form-data, application/octet-stream, text/plain, text/plain; charset=utf-8, ^[^/]+/vnd\\.[\\w.-]+\\+json$")); } @ParameterizedTest(name = "{index} test getters for scenario: {0}") @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, MediaTypeRegistry.createDefault()); assertThat(response.getHeaders()).hasSize(headerSize); if (headerSize > 0) { @@ -94,7 +95,8 @@ 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, MediaTypeRegistry.createDefault())); assertThat(exception.type()).isEqualTo(type); assertThat(exception).hasMessageThat().isEqualTo(msg); } diff --git a/src/test/java/io/vertx/tests/mediatype/MediaTypeInfoTest.java b/src/test/java/io/vertx/tests/mediatype/MediaTypeInfoTest.java new file mode 100644 index 00000000..eb27b5de --- /dev/null +++ b/src/test/java/io/vertx/tests/mediatype/MediaTypeInfoTest.java @@ -0,0 +1,88 @@ +package io.vertx.tests.mediatype; + +import static com.google.common.truth.Truth.assertThat; + +import io.vertx.openapi.mediatype.MediaTypeInfo; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class MediaTypeInfoTest { + + static Stream mediatypes() { + return Stream.of( + Arguments.of("text/html", "text", "html", null, Map.of()), + Arguments.of("application/json", "application", "json", null, Map.of()), + Arguments.of("image/png", "image", "png", null, Map.of()), + Arguments.of("application/vnd.api+json", "application", "vnd.api", "json", Map.of()), + Arguments.of("text/plain; charset=UTF-8", "text", "plain", null, Map.of("charset", "UTF-8")), + Arguments.of("audio/*", "audio", "*", null, Map.of()), + Arguments.of("application/xhtml+xml", "application", "xhtml", "xml", Map.of()), + Arguments.of("application/xml; charset=ISO-8859-1", "application", "xml", null, + Map.of("charset", "ISO-8859-1")), + Arguments.of("video/*; codec=H.264", "video", "*", null, Map.of("codec", "H.264")), + Arguments.of("application/octet-stream", "application", "octet-stream", null, Map.of()), + Arguments.of("application/json; charset=UTF-8; version=1.0", "application", "json", null, + orderedMap("charset", "UTF-8", "version", "1.0"))); + } + + private static Map orderedMap(String k1, String v1, String k2, String v2) { + var m = new LinkedHashMap(); + m.put(k1, v1); + m.put(k2, v2); + return m; + } + + @ParameterizedTest + @MethodSource("mediatypes") + void should_parse_mediatype_correctly(String mediatypestring, String type, String subtype, String suffix, + Map parameters) { + var info = MediaTypeInfo.of(mediatypestring); + assertThat(info.type()).isEqualTo(type); + assertThat(info.subtype()).isEqualTo(subtype); + assertThat(info.suffix()).isEqualTo(Optional.ofNullable(suffix)); + assertThat(info.parameters()).isEqualTo(parameters); + } + + @ParameterizedTest + @MethodSource("mediatypes") + void should_serialize_mediatype_info_correctly(String mediatypestring, String type, String subtype, String suffix, + Map parameters) { + var info = new MediaTypeInfo(type, subtype, suffix, parameters); + assertThat(info.toString()).isEqualTo(mediatypestring); + } + + @ParameterizedTest + @MethodSource("mediatypes") + void fullType_should_include_type_subtype_suffix(String mediatypestring) { + var info = MediaTypeInfo.of(mediatypestring); + assertThat(info.fullType()).isEqualTo(mediatypestring.split(";")[0].trim()); + } + + static Stream compatibility_data() { + return Stream.of( + Arguments.of("application/json", "application/json", true), + Arguments.of("application/json", "application/json; charset=utf-8", true), + Arguments.of("application/json; charset=iso-8851-1", "application/json; charset=utf-8", false), + Arguments.of("application/json; charset=utf-8", "application/json", false), + Arguments.of("application/*", "application/json", true), + Arguments.of("application/json", "application/*", false), + Arguments.of("application/*", "text/plain", false), + Arguments.of("application/*", "text/*", false), + Arguments.of("application/vnd.example", "application/vnd.example+xml", true), + Arguments.of("application/vnd.example", "application/vnd.example+json", true), + Arguments.of("application/vnd.example+json", "application/vnd.example", false), + Arguments.of("application/vnd.example+json", "application/vnd.example+xml", false), + Arguments.of("application/vnd.example+json", "application/vnd.example+json", true)); + } + + @ParameterizedTest + @MethodSource("compatibility_data") + void doesInclude_should_work_correctly(String src, String other, boolean expected) { + assertThat(MediaTypeInfo.of(src).doesInclude(MediaTypeInfo.of(other))).isEqualTo(expected); + } +} diff --git a/src/test/java/io/vertx/tests/mediatype/MediaTypeRegistryTest.java b/src/test/java/io/vertx/tests/mediatype/MediaTypeRegistryTest.java new file mode 100644 index 00000000..8066265d --- /dev/null +++ b/src/test/java/io/vertx/tests/mediatype/MediaTypeRegistryTest.java @@ -0,0 +1,68 @@ +package io.vertx.tests.mediatype; + +import static com.google.common.truth.Truth.assertThat; +import static io.vertx.openapi.mediatype.MediaTypePredicate.ofExactTypes; +import static io.vertx.openapi.mediatype.MediaTypeRegistration.alwaysValid; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.vertx.core.buffer.Buffer; +import io.vertx.openapi.contract.MediaType; +import io.vertx.openapi.mediatype.MediaTypeException; +import io.vertx.openapi.mediatype.MediaTypeRegistry; +import io.vertx.openapi.validation.ValidationContext; +import org.junit.jupiter.api.Test; + +public class MediaTypeRegistryTest { + + @Test + void emptyShouldNotSupportAnyMediaType() { + var r = MediaTypeRegistry.createEmpty(); + assertThat(r.isSupported(MediaType.TEXT_PLAIN)).isFalse(); + assertThat(r.isSupported(MediaType.TEXT_PLAIN_UTF8)).isFalse(); + assertThat(r.isSupported(MediaType.APPLICATION_JSON)).isFalse(); + assertThat(r.isSupported(MediaType.APPLICATION_HAL_JSON)).isFalse(); + assertThat(r.isSupported(MediaType.APPLICATION_OCTET_STREAM)).isFalse(); + assertThat(r.isSupported(MediaType.MULTIPART_FORM_DATA)).isFalse(); + } + + @Test + void defaultShouldSupportMediaTypes() { + var r = MediaTypeRegistry.createDefault(); + assertThat(r.isSupported(MediaType.TEXT_PLAIN)).isTrue(); + assertThat(r.isSupported(MediaType.TEXT_PLAIN_UTF8)).isTrue(); + assertThat(r.isSupported(MediaType.APPLICATION_JSON)).isTrue(); + assertThat(r.isSupported(MediaType.APPLICATION_HAL_JSON)).isTrue(); + assertThat(r.isSupported(MediaType.APPLICATION_OCTET_STREAM)).isTrue(); + assertThat(r.isSupported(MediaType.MULTIPART_FORM_DATA)).isTrue(); + } + + @Test + void addCustomTypeShouldMakeItSupported() { + var r = MediaTypeRegistry.createEmpty(); + var t = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml"; + r.register(alwaysValid(ofExactTypes(t))); + assertThat(r.isSupported(t)).isTrue(); + } + + @Test + void createContentAnalyserShouldThrowExceptionWhenMediaTypeIsNotSupported() { + var r = MediaTypeRegistry.createEmpty(); + assertThrows( + MediaTypeException.class, + () -> r.createContentAnalyser( + "text/plain", + Buffer.buffer(), + ValidationContext.REQUEST)); + } + + @Test + void createContentAnalyserShouldWorkForKnownMimetype() { + var r = MediaTypeRegistry.createDefault(); + var c = r.createContentAnalyser( + "text/plain", + Buffer.buffer(), + ValidationContext.REQUEST); + assertThat(c).isNotNull(); + } + +} diff --git a/src/test/java/io/vertx/tests/validation/analyser/ApplicationJsonAnalyserTest.java b/src/test/java/io/vertx/tests/mediatype/impl/ApplicationJsonAnalyserTest.java similarity index 94% rename from src/test/java/io/vertx/tests/validation/analyser/ApplicationJsonAnalyserTest.java rename to src/test/java/io/vertx/tests/mediatype/impl/ApplicationJsonAnalyserTest.java index c7dbc51b..ea1d53ab 100644 --- a/src/test/java/io/vertx/tests/validation/analyser/ApplicationJsonAnalyserTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/ApplicationJsonAnalyserTest.java @@ -10,7 +10,7 @@ * */ -package io.vertx.tests.validation.analyser; +package io.vertx.tests.mediatype.impl; import static com.google.common.truth.Truth.assertThat; import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; @@ -20,8 +20,8 @@ import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; +import io.vertx.openapi.mediatype.impl.ApplicationJsonAnalyser; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.analyser.ApplicationJsonAnalyser; import org.junit.jupiter.api.Test; class ApplicationJsonAnalyserTest { diff --git a/src/test/java/io/vertx/tests/validation/analyser/MultipartFormAnalyserTest.java b/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java similarity index 96% rename from src/test/java/io/vertx/tests/validation/analyser/MultipartFormAnalyserTest.java rename to src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java index fdea5234..a9360d8e 100644 --- a/src/test/java/io/vertx/tests/validation/analyser/MultipartFormAnalyserTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/MultipartFormAnalyserTest.java @@ -10,19 +10,19 @@ * */ -package io.vertx.tests.validation.analyser; +package io.vertx.tests.mediatype.impl; import static com.google.common.truth.Truth.assertThat; +import static io.vertx.openapi.mediatype.impl.MultipartFormAnalyser.extractBoundary; import static io.vertx.openapi.validation.ValidationContext.REQUEST; -import static io.vertx.openapi.validation.analyser.MultipartFormAnalyser.extractBoundary; import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; import static org.junit.jupiter.api.Assertions.assertThrows; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; +import io.vertx.openapi.mediatype.impl.MultipartFormAnalyser; import io.vertx.openapi.validation.ValidatorErrorType; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.analyser.MultipartFormAnalyser; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/src/test/java/io/vertx/tests/validation/analyser/MultipartPartTest.java b/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java similarity index 97% rename from src/test/java/io/vertx/tests/validation/analyser/MultipartPartTest.java rename to src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java index 79f2d7e6..d6bca459 100644 --- a/src/test/java/io/vertx/tests/validation/analyser/MultipartPartTest.java +++ b/src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java @@ -10,7 +10,7 @@ * */ -package io.vertx.tests.validation.analyser; +package io.vertx.tests.mediatype.impl; import static com.google.common.truth.Truth.assertThat; import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; @@ -19,9 +19,9 @@ import com.google.common.truth.Truth; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; +import io.vertx.openapi.mediatype.impl.MultipartPart; import io.vertx.openapi.validation.ValidatorErrorType; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.analyser.MultipartPart; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/src/test/java/io/vertx/tests/validation/analyser/ContentAnalyserTest.java b/src/test/java/io/vertx/tests/validation/analyser/ContentAnalyserTest.java deleted file mode 100644 index 606ea741..00000000 --- a/src/test/java/io/vertx/tests/validation/analyser/ContentAnalyserTest.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2024, SAP SE - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 - * which is available at https://www.apache.org/licenses/LICENSE-2.0. - * - * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 - * - */ - -package io.vertx.tests.validation.analyser; - -import static com.google.common.truth.Truth.assertThat; -import static io.vertx.openapi.contract.MediaType.APPLICATION_HAL_JSON; -import static io.vertx.openapi.contract.MediaType.APPLICATION_JSON; -import static io.vertx.openapi.contract.MediaType.APPLICATION_JSON_UTF8; -import static io.vertx.openapi.contract.MediaType.MULTIPART_FORM_DATA; -import static io.vertx.openapi.validation.analyser.ContentAnalyser.getContentAnalyser; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.vertx.openapi.contract.MediaType; -import io.vertx.openapi.validation.analyser.ApplicationJsonAnalyser; -import io.vertx.openapi.validation.analyser.MultipartFormAnalyser; -import org.junit.jupiter.api.Test; - -public class ContentAnalyserTest { - - @Test - void testGetContentAnalyser() { - assertThat(getContentAnalyser(mockMediaType(APPLICATION_JSON), null, null, null)) - .isInstanceOf(ApplicationJsonAnalyser.class); - assertThat(getContentAnalyser(mockMediaType(APPLICATION_JSON_UTF8), null, null, null)) - .isInstanceOf(ApplicationJsonAnalyser.class); - assertThat(getContentAnalyser(mockMediaType(APPLICATION_HAL_JSON), null, null, null)) - .isInstanceOf(ApplicationJsonAnalyser.class); - assertThat(getContentAnalyser(mockMediaType(MULTIPART_FORM_DATA), null, null, null)) - .isInstanceOf(MultipartFormAnalyser.class); - - assertThat(getContentAnalyser(mockMediaType("application/xml"), null, null, null)).isNull(); - } - - MediaType mockMediaType(String identifier) { - MediaType mockedMediaType = mock(MediaType.class); - when(mockedMediaType.getIdentifier()).thenReturn(identifier); - return mockedMediaType; - } -} diff --git a/src/test/resources/io/vertx/tests/contract/builder/contract-with-unsupported-mediatype.yaml b/src/test/resources/io/vertx/tests/contract/builder/contract-with-unsupported-mediatype.yaml new file mode 100644 index 00000000..b084c61e --- /dev/null +++ b/src/test/resources/io/vertx/tests/contract/builder/contract-with-unsupported-mediatype.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.1 +info: + title: Example with non-default supported mediatype + version: 2.0.0 + description: "" +paths: + /: + get: + responses: + "200": + description: "" + content: + text/tab-separated-values: + schema: + type: string + operationId: customResponse + post: + requestBody: + required: true + content: + application/vnd.openxmlformats-officedocument.wordprocessingml.document: + schema: + type: string + format: binary + responses: + "204": + description: "" + operationId: customRequest diff --git a/src/test/resources/io/vertx/tests/validation/analyser/.gitattributes b/src/test/resources/io/vertx/tests/mediatype/impl/.gitattributes similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/.gitattributes rename to src/test/resources/io/vertx/tests/mediatype/impl/.gitattributes diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_extended_content_type.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart_extended_content_type.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart_extended_content_type.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart_extended_content_type.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_id_no_body.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart_id_no_body.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart_id_no_body.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart_id_no_body.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart_invalid_structure.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart_invalid_structure.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure_2.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart_invalid_structure_2.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure_2.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart_invalid_structure_2.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_octet_stream.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart_octet_stream.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart_octet_stream.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart_octet_stream.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_part_invalid_contenttype.txt b/src/test/resources/io/vertx/tests/mediatype/impl/multipart_part_invalid_contenttype.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/multipart_part_invalid_contenttype.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/multipart_part_invalid_contenttype.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/part1.txt b/src/test/resources/io/vertx/tests/mediatype/impl/part1.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/part1.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/part1.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/part2.txt b/src/test/resources/io/vertx/tests/mediatype/impl/part2.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/part2.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/part2.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/part3.txt b/src/test/resources/io/vertx/tests/mediatype/impl/part3.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/part3.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/part3.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/part_without_body.txt b/src/test/resources/io/vertx/tests/mediatype/impl/part_without_body.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/part_without_body.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/part_without_body.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/part_without_contenttype.txt b/src/test/resources/io/vertx/tests/mediatype/impl/part_without_contenttype.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/part_without_contenttype.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/part_without_contenttype.txt diff --git a/src/test/resources/io/vertx/tests/validation/analyser/part_without_name.txt b/src/test/resources/io/vertx/tests/mediatype/impl/part_without_name.txt similarity index 100% rename from src/test/resources/io/vertx/tests/validation/analyser/part_without_name.txt rename to src/test/resources/io/vertx/tests/mediatype/impl/part_without_name.txt