diff --git a/src/main/java/io/vertx/openapi/contract/MediaType.java b/src/main/java/io/vertx/openapi/contract/MediaType.java index 54acb75f..f4130ad4 100644 --- a/src/main/java/io/vertx/openapi/contract/MediaType.java +++ b/src/main/java/io/vertx/openapi/contract/MediaType.java @@ -12,7 +12,6 @@ package io.vertx.openapi.contract; -import io.netty.handler.codec.http.HttpHeaderValues; import io.vertx.codegen.annotations.VertxGen; import io.vertx.json.schema.JsonSchema; @@ -29,18 +28,24 @@ @VertxGen public interface MediaType extends OpenAPIObject { - String APPLICATION_HAL_JSON = "application/hal+json"; - String APPLICATION_JSON = HttpHeaderValues.APPLICATION_JSON.toString(); + String APPLICATION_JSON = "application/json"; String APPLICATION_JSON_UTF8 = APPLICATION_JSON + "; charset=utf-8"; - String MULTIPART_FORM_DATA = HttpHeaderValues.MULTIPART_FORM_DATA.toString(); - List SUPPORTED_MEDIA_TYPES = Arrays.asList(APPLICATION_JSON, APPLICATION_JSON_UTF8, MULTIPART_FORM_DATA, APPLICATION_HAL_JSON); + String MULTIPART_FORM_DATA = "multipart/form-data"; + String APPLICATION_HAL_JSON = "application/hal+json"; + String APPLICATION_OCTET_STREAM = "application/octet-stream"; + List SUPPORTED_MEDIA_TYPES = Arrays.asList(APPLICATION_JSON, APPLICATION_JSON_UTF8, MULTIPART_FORM_DATA, + APPLICATION_HAL_JSON, APPLICATION_OCTET_STREAM); static boolean isMediaTypeSupported(String type) { return SUPPORTED_MEDIA_TYPES.contains(type.toLowerCase()); } /** - * @return the schema defining the content of the request. + * This method returns the schema defined in the media type. + *

+ * In OpenAPI 3.1 it is allowed to define an empty media type model. In this case the method returns null. + * + * @return the schema defined in the media type model, or null in case no media type model was defined. */ JsonSchema getSchema(); 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 10fc74d0..150ff0f4 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/MediaTypeImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/MediaTypeImpl.java @@ -28,11 +28,22 @@ public class MediaTypeImpl implements MediaType { public MediaTypeImpl(String identifier, JsonObject mediaTypeModel) { this.identifier = identifier; this.mediaTypeModel = mediaTypeModel; - JsonObject schemaJson = mediaTypeModel.getJsonObject(KEY_SCHEMA); - if (schemaJson == null || schemaJson.isEmpty()) { + + if (mediaTypeModel == null) { throw createUnsupportedFeature("Media Type without a schema"); } - schema = JsonSchema.of(schemaJson); + + if (mediaTypeModel.isEmpty()) { + // OpenAPI 3.1 allows defining MediaTypes without a schema. + schema = null; + } else { + JsonObject schemaJson = mediaTypeModel.getJsonObject(KEY_SCHEMA); + if (schemaJson == null || schemaJson.isEmpty()) { + throw createUnsupportedFeature("Media Type without a schema"); + } + schema = JsonSchema.of(schemaJson); + + } } @Override 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 f7460481..2505dfe3 100644 --- a/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java +++ b/src/main/java/io/vertx/openapi/contract/impl/RequestBodyImpl.java @@ -84,7 +84,7 @@ public MediaType determineContentType(String contentType) { if (contentType == null) { return null; } - + String condensedIdentifier = removeWhiteSpaces(contentType); if (content.containsKey(condensedIdentifier)) { return content.get(condensedIdentifier); diff --git a/src/main/java/io/vertx/openapi/impl/Utils.java b/src/main/java/io/vertx/openapi/impl/Utils.java index 3fe73f77..f8576414 100644 --- a/src/main/java/io/vertx/openapi/impl/Utils.java +++ b/src/main/java/io/vertx/openapi/impl/Utils.java @@ -55,10 +55,10 @@ public static Future readYamlOrJson(Vertx vertx, String path) { }); } - /** + /** * Reads YAML string and transforms it into a JsonObject. * - * @param path The yamlString proper YAML formatted STring + * @param yamlString The yamlString proper YAML formatted STring * @return A succeeded Future holding the JsonObject, or a failed Future if the file could not be parsed. */ public static Future yamlStringToJson(String yamlString) { diff --git a/src/main/java/io/vertx/openapi/validation/SchemaValidationException.java b/src/main/java/io/vertx/openapi/validation/SchemaValidationException.java index 6454ca40..a5174c78 100644 --- a/src/main/java/io/vertx/openapi/validation/SchemaValidationException.java +++ b/src/main/java/io/vertx/openapi/validation/SchemaValidationException.java @@ -40,27 +40,23 @@ public static SchemaValidationException createInvalidValueParameter(Parameter pa return new SchemaValidationException(msg, INVALID_VALUE, outputUnit, cause); } - public static SchemaValidationException createInvalidValueRequestBody(OutputUnit outputUnit, - JsonSchemaValidationException cause) { - String msg = String.format("The value of the request body is invalid. Reason: %s", extractReason(outputUnit)); - return new SchemaValidationException(msg, INVALID_VALUE, outputUnit, cause); - } - - public static SchemaValidationException createInvalidValueResponseBody(OutputUnit outputUnit, - JsonSchemaValidationException cause) { - String msg = String.format("The value of the response body is invalid. Reason: %s", extractReason(outputUnit)); + public static SchemaValidationException createInvalidValueBody(OutputUnit outputUnit, + ValidationContext requestOrResponse, + JsonSchemaValidationException cause) { + String msg = String.format("The value of the " + requestOrResponse + " body is invalid. Reason: %s", + extractReason(outputUnit)); return new SchemaValidationException(msg, INVALID_VALUE, outputUnit, cause); } public static SchemaValidationException createMissingValueRequestBody(OutputUnit outputUnit, - JsonSchemaValidationException cause) { + JsonSchemaValidationException cause) { String msg = String.format("The value of the request body is missing. Reason: %s", extractReason(outputUnit)); return new SchemaValidationException(msg, MISSING_REQUIRED_PARAMETER, outputUnit, cause); } public static SchemaValidationException createErrorFromOutputUnitType(Parameter parameter, OutputUnit outputUnit, JsonSchemaValidationException cause) { - switch(outputUnit.getErrorType()) { + switch (outputUnit.getErrorType()) { case MISSING_VALUE: return createMissingValueRequestBody(outputUnit, cause); case INVALID_VALUE: diff --git a/src/main/java/io/vertx/openapi/validation/ValidationContext.java b/src/main/java/io/vertx/openapi/validation/ValidationContext.java new file mode 100644 index 00000000..1352d660 --- /dev/null +++ b/src/main/java/io/vertx/openapi/validation/ValidationContext.java @@ -0,0 +1,22 @@ +/* + * 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; + +public enum ValidationContext { + REQUEST, RESPONSE; + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/src/main/java/io/vertx/openapi/validation/analyser/ApplicationJsonAnalyser.java b/src/main/java/io/vertx/openapi/validation/analyser/ApplicationJsonAnalyser.java new file mode 100644 index 00000000..d2f47a3f --- /dev/null +++ b/src/main/java/io/vertx/openapi/validation/analyser/ApplicationJsonAnalyser.java @@ -0,0 +1,34 @@ +/* + * 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 io.vertx.core.buffer.Buffer; +import io.vertx.openapi.validation.ValidationContext; + +public class ApplicationJsonAnalyser extends ContentAnalyser { + private Object decodedValue; + + public ApplicationJsonAnalyser(String contentType, Buffer content, ValidationContext context) { + super(contentType, content, context); + } + + @Override + public void checkSyntacticalCorrectness() { + decodedValue = decodeJsonContent(content, requestOrResponse); + } + + @Override + public Object transform() { + return decodedValue; + } +} diff --git a/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java new file mode 100644 index 00000000..99302ee0 --- /dev/null +++ b/src/main/java/io/vertx/openapi/validation/analyser/ContentAnalyser.java @@ -0,0 +1,134 @@ +/* + * 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 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; + +import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; + +/** + * 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 OctetStreamAnalyser extends ContentAnalyser { + public OctetStreamAnalyser(String contentType, Buffer content, ValidationContext context) { + super(contentType, content, context); + } + + @Override + public void checkSyntacticalCorrectness() { + // no syntax check for octet-stream + } + + @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: + return new OctetStreamAnalyser(contentType, content, context); + default: + return null; + } + } + + protected String contentType; + protected Buffer content; + protected 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/transformer/MultipartFormTransformer.java b/src/main/java/io/vertx/openapi/validation/analyser/MultipartFormAnalyser.java similarity index 55% rename from src/main/java/io/vertx/openapi/validation/transformer/MultipartFormTransformer.java rename to src/main/java/io/vertx/openapi/validation/analyser/MultipartFormAnalyser.java index acb313f3..23368ecb 100644 --- a/src/main/java/io/vertx/openapi/validation/transformer/MultipartFormTransformer.java +++ b/src/main/java/io/vertx/openapi/validation/analyser/MultipartFormAnalyser.java @@ -10,26 +10,38 @@ * */ -package io.vertx.openapi.validation.transformer; +package io.vertx.openapi.validation.analyser; import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.DecodeException; +import io.vertx.core.json.Json; import io.vertx.core.json.JsonObject; -import io.vertx.openapi.contract.MediaType; -import io.vertx.openapi.validation.ValidatableRequest; -import io.vertx.openapi.validation.ValidatableResponse; +import io.vertx.openapi.validation.ValidationContext; import io.vertx.openapi.validation.ValidatorException; import java.util.List; import static io.netty.handler.codec.http.HttpHeaderValues.MULTIPART_FORM_DATA; -import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT; -public class MultipartFormTransformer implements BodyTransformer { +public class MultipartFormAnalyser extends ContentAnalyser { private static final String BOUNDARY = "boundary="; - private static final ApplicationJsonTransformer JSON_TRANSFORMER = new ApplicationJsonTransformer(); + private List parts; + + /** + * 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 MultipartFormAnalyser(String contentType, Buffer content, ValidationContext context) { + super(contentType, content, context); + } + + // VisibleForTesting public static String extractBoundary(String contentType) { String[] parts = contentType.split(BOUNDARY, 2); if (parts.length == 2 && !parts[1].isBlank()) { @@ -38,23 +50,27 @@ public static String extractBoundary(String contentType) { return null; } - public static Object transform(MediaType type, Buffer body, String contentType, String responseOrRequest) { + @Override + public void checkSyntacticalCorrectness() { if (contentType == null || contentType.isEmpty() || !contentType.startsWith(MULTIPART_FORM_DATA.toString())) { - String msg = "The expected multipart/form-data " + responseOrRequest + " doesn't contain the required " + + String msg = "The expected multipart/form-data " + requestOrResponse + " doesn't contain the required " + "content-type header."; throw new ValidatorException(msg, MISSING_REQUIRED_PARAMETER); } String boundary = extractBoundary(contentType); if (boundary == null) { - String msg = "The expected multipart/form-data " + responseOrRequest + " doesn't contain the required boundary " + + String msg = "The expected multipart/form-data " + requestOrResponse + " doesn't contain the required boundary " + "information."; throw new ValidatorException(msg, MISSING_REQUIRED_PARAMETER); } - JsonObject formData = new JsonObject(); + parts = MultipartPart.fromMultipartBody(content.toString(), boundary); + } - List parts = MultipartPart.fromMultipartBody(body.toString(), boundary); + @Override + public Object transform() { + JsonObject formData = new JsonObject(); for (MultipartPart part : parts) { if (part.getBody() == null) { continue; @@ -63,18 +79,14 @@ public static Object transform(MediaType type, Buffer body, String contentType, // getContentType() can't be null if (part.getContentType().startsWith("text/plain")) { try { - formData.put(part.getName(), JSON_TRANSFORMER.transform(null, part.getBody())); - } catch (ValidatorException ve) { - if (ve.type() == ILLEGAL_VALUE) { - // Value isn't a number, boolean, etc. -> therefore it is treated as a string. - Buffer quotedBody = Buffer.buffer("\"").appendBuffer(part.getBody()).appendString("\""); - formData.put(part.getName(), JSON_TRANSFORMER.transform(null, quotedBody)); - } else { - throw ve; - } + formData.put(part.getName(), Json.decodeValue(part.getBody())); + } catch (DecodeException de) { + // Value isn't a number, boolean, etc. -> therefore it is treated as a string. + Buffer quotedBody = Buffer.buffer("\"").appendBuffer(part.getBody()).appendString("\""); + formData.put(part.getName(), decodeJsonContent(quotedBody, requestOrResponse)); } } else if (part.getContentType().startsWith("application/json")) { - formData.put(part.getName(), JSON_TRANSFORMER.transform(null, part.getBody())); + formData.put(part.getName(), decodeJsonContent(part.getBody(), requestOrResponse)); } else if (part.getContentType().startsWith("application/octet-stream")) { formData.put(part.getName(), part.getBody()); } else { @@ -86,14 +98,4 @@ public static Object transform(MediaType type, Buffer body, String contentType, return formData; } - - @Override - public Object transformRequest(MediaType type, ValidatableRequest request) { - return transform(type, request.getBody().getBuffer(), request.getContentType(), "request"); - } - - @Override - public Object transformResponse(MediaType type, ValidatableResponse response) { - return transform(type, response.getBody().getBuffer(), response.getContentType(), "response"); - } } diff --git a/src/main/java/io/vertx/openapi/validation/transformer/MultipartPart.java b/src/main/java/io/vertx/openapi/validation/analyser/MultipartPart.java similarity index 92% rename from src/main/java/io/vertx/openapi/validation/transformer/MultipartPart.java rename to src/main/java/io/vertx/openapi/validation/analyser/MultipartPart.java index b00b89ac..cd83600e 100644 --- a/src/main/java/io/vertx/openapi/validation/transformer/MultipartPart.java +++ b/src/main/java/io/vertx/openapi/validation/analyser/MultipartPart.java @@ -10,7 +10,7 @@ * */ -package io.vertx.openapi.validation.transformer; +package io.vertx.openapi.validation.analyser; import io.vertx.core.buffer.Buffer; import io.vertx.openapi.validation.ValidatorException; @@ -26,7 +26,8 @@ import static java.util.regex.Pattern.CASE_INSENSITIVE; public class MultipartPart { - private static final Pattern NAME_PATTERN = Pattern.compile("Content-Disposition: form-data; name=\"(.*?)\"", CASE_INSENSITIVE); + private static final Pattern NAME_PATTERN = Pattern.compile("Content-Disposition: form-data; name=\"(.*?)\"", + CASE_INSENSITIVE); private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("Content-Type: (.*)", CASE_INSENSITIVE); private final String name; @@ -70,7 +71,8 @@ public static MultipartPart parsePart(String rawPart) { // if no empty line exists, there are only headers String headerSection = sectionDelimiter == -1 ? rawPart : rawPart.substring(0, sectionDelimiter); - String body = sectionDelimiter == -1 ? null : rawPart.substring(sectionDelimiter + sectionDelimiterPattern.length()); + String body = sectionDelimiter == -1 ? null : + rawPart.substring(sectionDelimiter + sectionDelimiterPattern.length()); String name = parsePattern(NAME_PATTERN, headerSection).orElseThrow(() -> { String msg = "A part of the multipart message doesn't contain a name."; @@ -112,7 +114,8 @@ public boolean equals(Object o) { } MultipartPart that = (MultipartPart) o; - return Objects.equals(name, that.name) && Objects.equals(contentType, that.contentType) && Objects.equals(body, that.body); + return Objects.equals(name, that.name) && Objects.equals(contentType, that.contentType) && Objects.equals(body, + that.body); } @Override 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 1aadffcc..661fc163 100644 --- a/src/main/java/io/vertx/openapi/validation/impl/BaseValidator.java +++ b/src/main/java/io/vertx/openapi/validation/impl/BaseValidator.java @@ -14,46 +14,81 @@ import io.vertx.core.Future; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.json.schema.JsonSchemaValidationException; +import io.vertx.json.schema.OutputUnit; import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContract; import io.vertx.openapi.contract.Operation; -import io.vertx.openapi.validation.transformer.ApplicationJsonTransformer; -import io.vertx.openapi.validation.transformer.BodyTransformer; -import io.vertx.openapi.validation.transformer.MultipartFormTransformer; - -import java.util.HashMap; -import java.util.Map; +import io.vertx.openapi.validation.ValidationContext; +import io.vertx.openapi.validation.ValidatorException; +import io.vertx.openapi.validation.analyser.ContentAnalyser; import static io.vertx.core.Future.failedFuture; import static io.vertx.core.Future.succeededFuture; +import static io.vertx.openapi.validation.SchemaValidationException.createInvalidValueBody; +import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT; import static io.vertx.openapi.validation.ValidatorException.createOperationIdInvalid; public class BaseValidator { protected final Vertx vertx; protected final OpenAPIContract contract; - protected final Map bodyTransformers; public BaseValidator(Vertx vertx, OpenAPIContract contract) { this.vertx = vertx; this.contract = contract; - - bodyTransformers = new HashMap<>(); - bodyTransformers.put(MediaType.APPLICATION_JSON, new ApplicationJsonTransformer()); - bodyTransformers.put(MediaType.APPLICATION_JSON_UTF8, new ApplicationJsonTransformer()); - bodyTransformers.put(MediaType.MULTIPART_FORM_DATA, new MultipartFormTransformer()); - bodyTransformers.put(MediaType.APPLICATION_HAL_JSON, new ApplicationJsonTransformer()); } - public boolean containsTransformer(String transformer) { - return bodyTransformers.containsKey(transformer); - } - - // VisibleForTesting - public Future getOperation(String operationId) { + protected Future getOperation(String operationId) { Operation operation = contract.operation(operationId); if (operation == null) { return failedFuture(createOperationIdInvalid(operationId)); } return succeededFuture(operation); } + + protected boolean isSchemaValidationRequired(MediaType mediaType) { + if (mediaType.getSchema() == null) { + // content should be treated as binary, because no media model is defined (OpenAPI 3.1) + return false; + } else { + String type = mediaType.getSchema().get("type"); + String format = mediaType.getSchema().get("format"); + + // Also a binary string could have length restrictions, therefore we need to preclude further properties. + boolean noFurtherProperties = mediaType.getSchema().fieldNames().size() == 2; + + if ("string".equalsIgnoreCase(type) && "binary".equalsIgnoreCase(format) && noFurtherProperties) { + return false; + } + return true; + } + } + + 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) { + throw new ValidatorException("The format of the " + requestOrResponse + " body is not supported", + UNSUPPORTED_VALUE_FORMAT); + } + + // Throws an exception if the content is not syntactically correct + contentAnalyser.checkSyntacticalCorrectness(); + + if (isSchemaValidationRequired(mediaType)) { + Object transformedValue = contentAnalyser.transform(); + OutputUnit result = contract.getSchemaRepository().validator(mediaType.getSchema()).validate(transformedValue); + try { + result.checkValidity(); + return new RequestParameterImpl(transformedValue); + } catch (JsonSchemaValidationException e) { + throw createInvalidValueBody(result, requestOrResponse, e); + } + } + + return new RequestParameterImpl(rawContent); + } } diff --git a/src/main/java/io/vertx/openapi/validation/impl/RequestValidatorImpl.java b/src/main/java/io/vertx/openapi/validation/impl/RequestValidatorImpl.java index 72f045b9..18c96af8 100644 --- a/src/main/java/io/vertx/openapi/validation/impl/RequestValidatorImpl.java +++ b/src/main/java/io/vertx/openapi/validation/impl/RequestValidatorImpl.java @@ -14,6 +14,7 @@ import io.vertx.core.Future; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpServerRequest; import io.vertx.json.schema.JsonSchemaValidationException; import io.vertx.json.schema.OutputUnit; @@ -28,7 +29,6 @@ import io.vertx.openapi.validation.ValidatableRequest; import io.vertx.openapi.validation.ValidatedRequest; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.transformer.BodyTransformer; import io.vertx.openapi.validation.transformer.FormTransformer; import io.vertx.openapi.validation.transformer.LabelTransformer; import io.vertx.openapi.validation.transformer.MatrixTransformer; @@ -45,9 +45,8 @@ import static io.vertx.openapi.contract.Style.MATRIX; import static io.vertx.openapi.contract.Style.SIMPLE; import static io.vertx.openapi.validation.SchemaValidationException.createErrorFromOutputUnitType; -import static io.vertx.openapi.validation.SchemaValidationException.createInvalidValueRequestBody; +import static io.vertx.openapi.validation.ValidationContext.REQUEST; import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; -import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT; import static io.vertx.openapi.validation.ValidatorException.createMissingRequiredParameter; import static io.vertx.openapi.validation.ValidatorException.createOperationNotFound; import static io.vertx.openapi.validation.ValidatorException.createUnsupportedValueFormat; @@ -123,6 +122,7 @@ public RequestParameter validateParameter(Parameter parameter, RequestParameter throw createUnsupportedValueFormat(parameter); } Object transformedValue = transformer.transform(parameter, String.valueOf(value.get())); + OutputUnit result = contract .getSchemaRepository() .validator(parameter.getSchema()) @@ -151,18 +151,8 @@ public RequestParameter validateBody(RequestBody requestBody, ValidatableRequest } MediaType mediaType = requestBody.determineContentType(request.getContentType()); - BodyTransformer transformer = mediaType == null ? null : bodyTransformers.get(mediaType.getIdentifier()); - if (transformer == null) { - throw new ValidatorException("The format of the request body is not supported", UNSUPPORTED_VALUE_FORMAT); - } - Object transformedValue = transformer.transformRequest(mediaType, request); - OutputUnit result = contract.getSchemaRepository().validator(mediaType.getSchema()).validate(transformedValue); + Buffer content = request.getBody().getBuffer(Buffer.buffer()); - try { - result.checkValidity(); - return new RequestParameterImpl(transformedValue); - } catch (JsonSchemaValidationException e) { - throw createInvalidValueRequestBody(result, e); - } + return validate(mediaType, request.getContentType(), content, REQUEST); } } diff --git a/src/main/java/io/vertx/openapi/validation/impl/ResponseValidatorImpl.java b/src/main/java/io/vertx/openapi/validation/impl/ResponseValidatorImpl.java index bf66f5fa..fd6ad188 100644 --- a/src/main/java/io/vertx/openapi/validation/impl/ResponseValidatorImpl.java +++ b/src/main/java/io/vertx/openapi/validation/impl/ResponseValidatorImpl.java @@ -14,6 +14,7 @@ import io.vertx.core.Future; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.json.schema.JsonSchemaValidationException; import io.vertx.json.schema.OutputUnit; import io.vertx.openapi.contract.MediaType; @@ -25,7 +26,6 @@ import io.vertx.openapi.validation.ValidatableResponse; import io.vertx.openapi.validation.ValidatedResponse; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.transformer.BodyTransformer; import io.vertx.openapi.validation.transformer.ParameterTransformer; import io.vertx.openapi.validation.transformer.SimpleTransformer; @@ -36,9 +36,8 @@ import static io.vertx.core.Future.failedFuture; import static io.vertx.core.Future.succeededFuture; import static io.vertx.openapi.validation.SchemaValidationException.createInvalidValueParameter; -import static io.vertx.openapi.validation.SchemaValidationException.createInvalidValueResponseBody; +import static io.vertx.openapi.validation.ValidationContext.RESPONSE; import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; -import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT; import static io.vertx.openapi.validation.ValidatorException.createMissingRequiredParameter; import static io.vertx.openapi.validation.ValidatorException.createResponseNotFound; @@ -104,21 +103,9 @@ public ResponseParameter validateBody(Response response, ValidatableResponse par MISSING_REQUIRED_PARAMETER); } - String mediaTypeIdentifier = params.getContentType(); - MediaType mediaType = response.getContent().get(mediaTypeIdentifier); - BodyTransformer transformer = bodyTransformers.get(mediaTypeIdentifier); - if (transformer == null || mediaType == null) { - throw new ValidatorException("The format of the response body is not supported", UNSUPPORTED_VALUE_FORMAT); - } - - Object transformedValue = transformer.transformResponse(mediaType, params); - OutputUnit result = contract.getSchemaRepository().validator(mediaType.getSchema()).validate(transformedValue); + MediaType mediaType = response.getContent().get(params.getContentType()); + Buffer content = params.getBody().getBuffer(Buffer.buffer()); - try { - result.checkValidity(); - return new RequestParameterImpl(transformedValue); - } catch (JsonSchemaValidationException e) { - throw createInvalidValueResponseBody(result, e); - } + return validate(mediaType, params.getContentType(), content, RESPONSE); } } diff --git a/src/main/java/io/vertx/openapi/validation/transformer/ApplicationJsonTransformer.java b/src/main/java/io/vertx/openapi/validation/transformer/ApplicationJsonTransformer.java deleted file mode 100644 index 72efe7fd..00000000 --- a/src/main/java/io/vertx/openapi/validation/transformer/ApplicationJsonTransformer.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2023, 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.transformer; - -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.ValidatableRequest; -import io.vertx.openapi.validation.ValidatableResponse; -import io.vertx.openapi.validation.ValidatorException; - -import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; - -public class ApplicationJsonTransformer implements BodyTransformer { - - @Override - public Object transformRequest(MediaType type, ValidatableRequest request) { - return transform(type, request.getBody().getBuffer()); - } - - @Override - public Object transformResponse(MediaType type, ValidatableResponse response) { - return transform(type, response.getBody().getBuffer()); - } - - // used in MultipartFormTransformer - Object transform(MediaType type, Buffer body) { - try { - return Json.decodeValue(body); - } catch (DecodeException e) { - throw new ValidatorException("The request body can't be decoded", ILLEGAL_VALUE); - } - } -} diff --git a/src/main/java/io/vertx/openapi/validation/transformer/BodyTransformer.java b/src/main/java/io/vertx/openapi/validation/transformer/BodyTransformer.java deleted file mode 100644 index f1909203..00000000 --- a/src/main/java/io/vertx/openapi/validation/transformer/BodyTransformer.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2023, 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.transformer; - -import io.vertx.openapi.contract.MediaType; -import io.vertx.openapi.validation.ValidatableRequest; -import io.vertx.openapi.validation.ValidatableResponse; - -public interface BodyTransformer { - - /** - * Transforms the body of a request into a format that can be validated by the - * {@link io.vertx.openapi.validation.RequestValidator}. - * - * @param type the media type of the body. - * @param request the request with the body to transform. - * @return the transformed body. - */ - Object transformRequest(MediaType type, ValidatableRequest request); - - /** - * Transforms the body of a response into a format that can be validated by the - * {@link io.vertx.openapi.validation.ResponseValidator}. - * - * @param type the media type of the body. - * @param response the response with the body to transform. - * @return the transformed body. - */ - Object transformResponse(MediaType type, ValidatableResponse response); -} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 988e5264..e35fe18e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -16,6 +16,8 @@ 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; } 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 107015fb..f8b85947 100644 --- a/src/test/java/io/vertx/tests/contract/impl/MediaTypeImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/MediaTypeImplTest.java @@ -18,6 +18,12 @@ import io.vertx.openapi.contract.OpenAPIContractException; import io.vertx.openapi.contract.impl.MediaTypeImpl; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; import static com.google.common.truth.Truth.assertThat; import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; @@ -28,13 +34,24 @@ class MediaTypeImplTest { private static final String DUMMY_IDENTIFIER = APPLICATION_JSON.toString(); - @Test - void testGetters() { - JsonObject model = new JsonObject().put("schema", stringSchema().toJson()); - MediaType mediaType = new MediaTypeImpl(DUMMY_IDENTIFIER, model); + private static Stream testGetters() { + return Stream.of( + Arguments.of("MediaType model defined", new JsonObject().put("schema", stringSchema().toJson()), List.of("type" + , "$id")), + Arguments.of("No MediaType model defined", EMPTY_JSON_OBJECT, List.of()) + ); + } - assertThat(mediaType.getOpenAPIModel()).isEqualTo(model); - assertThat(mediaType.getSchema().fieldNames()).containsExactly("type", "$id"); + @ParameterizedTest(name = "{index} test getters for scenario: {0}") + @MethodSource + void testGetters(String scenario, JsonObject mediaTypeModel, List fieldNames) { + MediaType mediaType = new MediaTypeImpl(DUMMY_IDENTIFIER, mediaTypeModel); + assertThat(mediaType.getOpenAPIModel()).isEqualTo(mediaTypeModel); + if (fieldNames.isEmpty()) { + assertThat(mediaType.getSchema()).isNull(); + } else { + assertThat(mediaType.getSchema().fieldNames()).containsExactlyElementsIn(fieldNames); + } assertThat(mediaType.getIdentifier()).isEqualTo(DUMMY_IDENTIFIER); } @@ -42,16 +59,21 @@ void testGetters() { void testExceptions() { String msg = "The passed OpenAPI contract contains a feature that is not supported: Media Type without a schema"; - OpenAPIContractException exceptionNull = + OpenAPIContractException exceptionNoModel = + assertThrows(OpenAPIContractException.class, () -> new MediaTypeImpl(DUMMY_IDENTIFIER, null)); + 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"))); - assertThat(exceptionNull.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); - assertThat(exceptionNull).hasMessageThat().isEqualTo(msg); + assertThat(exceptionSchemaNull.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); + assertThat(exceptionSchemaNull).hasMessageThat().isEqualTo(msg); - OpenAPIContractException exceptionEmpty = + OpenAPIContractException exceptionSchemaEmpty = assertThrows(OpenAPIContractException.class, () -> new MediaTypeImpl(DUMMY_IDENTIFIER, new JsonObject().put("schema", EMPTY_JSON_OBJECT))); - assertThat(exceptionEmpty.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); - assertThat(exceptionEmpty).hasMessageThat().isEqualTo(msg); + assertThat(exceptionSchemaEmpty.type()).isEqualTo(ContractErrorType.UNSUPPORTED_FEATURE); + assertThat(exceptionSchemaEmpty).hasMessageThat().isEqualTo(msg); } } 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 05443fa1..c42d55dc 100644 --- a/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/RequestBodyImplTest.java @@ -30,11 +30,11 @@ import java.util.stream.Stream; import static com.google.common.truth.Truth.assertThat; -import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; import static io.vertx.openapi.contract.ContractErrorType.INVALID_SPEC; import static io.vertx.openapi.contract.ContractErrorType.UNSUPPORTED_FEATURE; 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 org.junit.jupiter.api.Assertions.assertThrows; @ExtendWith(VertxExtension.class) @@ -77,7 +77,7 @@ private static Stream testExceptions() { Arguments.of("0002_RequestBody_With_Content_Type_Text_Plain", 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") + " multipart/form-data, application/hal+json, application/octet-stream") ); } 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 7ae1316c..6cb0e331 100644 --- a/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java +++ b/src/test/java/io/vertx/tests/contract/impl/ResponseImplTest.java @@ -32,8 +32,8 @@ import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; import static io.vertx.json.schema.common.dsl.SchemaType.INTEGER; import static io.vertx.json.schema.common.dsl.SchemaType.STRING; -import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; import static io.vertx.openapi.contract.ContractErrorType.UNSUPPORTED_FEATURE; +import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath; import static org.junit.jupiter.api.Assertions.assertThrows; @ExtendWith(VertxExtension.class) @@ -69,7 +69,7 @@ private static Stream testExceptions() { Arguments.of("0000_Response_With_Content_Type_Text_Plain", 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") + "multipart/form-data, application/hal+json, application/octet-stream") ); } diff --git a/src/test/java/io/vertx/tests/test/E2ETest.java b/src/test/java/io/vertx/tests/test/E2ETest.java index 259f41eb..b3d66f12 100644 --- a/src/test/java/io/vertx/tests/test/E2ETest.java +++ b/src/test/java/io/vertx/tests/test/E2ETest.java @@ -23,11 +23,11 @@ import io.vertx.junit5.Timeout; import io.vertx.junit5.VertxTestContext; import io.vertx.openapi.contract.OpenAPIContract; -import io.vertx.tests.test.base.HttpServerTestBase; import io.vertx.openapi.validation.RequestValidator; import io.vertx.openapi.validation.ResponseValidator; import io.vertx.openapi.validation.ValidatableResponse; import io.vertx.openapi.validation.ValidatedRequest; +import io.vertx.tests.test.base.HttpServerTestBase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; @@ -99,23 +99,23 @@ void testExtractPath(String basePath, VertxTestContext testContext) { public void sendMultipartFormDataRequest(VertxTestContext testContext) { Path path = getRelatedTestResourcePath(E2ETest.class).resolve("multipart.txt"); JsonObject expectedPetMetadata = new JsonObject() - .put("friends", new JsonArray().add(123).add(456).add(789)) - .put("contactInformation", new JsonObject() - .put("name", "Example") - .put("email", "example@example.com") - .put("phone", "5555555555")); + .put("friends", new JsonArray().add(123).add(456).add(789)) + .put("contactInformation", new JsonObject() + .put("name", "Example") + .put("email", "example@example.com") + .put("phone", "5555555555")); setupContract("", testContext).compose(v -> createValidationHandler(req -> { - testContext.verify(() -> { - assertThat(req.getBody()).isNotNull(); - JsonObject jsonReq = req.getBody().getJsonObject(); - assertThat(jsonReq.getLong("petId")).isEqualTo(1234L); - assertThat(jsonReq.getJsonObject("petMetadata")).isEqualTo(expectedPetMetadata); - assertThat(jsonReq.getBuffer("petPicture")).isNotNull(); - testContext.completeNow(); - }); - return ValidatableResponse.create(201); - },contract.operation("uploadPet").getOperationId(), testContext)) + testContext.verify(() -> { + assertThat(req.getBody()).isNotNull(); + JsonObject jsonReq = req.getBody().getJsonObject(); + assertThat(jsonReq.getLong("petId")).isEqualTo(1234L); + assertThat(jsonReq.getJsonObject("petMetadata")).isEqualTo(expectedPetMetadata); + assertThat(jsonReq.getBuffer("petPicture")).isNotNull(); + testContext.completeNow(); + }); + return ValidatableResponse.create(201); + }, contract.operation("uploadPet").getOperationId(), testContext)) .compose(v -> createRequest(HttpMethod.POST, "/pets/upload")) .map(request -> request.putHeader(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=4ad8accc990e99c2")) .map(request -> request.putHeader(HttpHeaders.CONTENT_DISPOSITION, "")) diff --git a/src/test/java/io/vertx/tests/validation/SchemaValidationExceptionTest.java b/src/test/java/io/vertx/tests/validation/SchemaValidationExceptionTest.java index a4b31901..2377b883 100644 --- a/src/test/java/io/vertx/tests/validation/SchemaValidationExceptionTest.java +++ b/src/test/java/io/vertx/tests/validation/SchemaValidationExceptionTest.java @@ -24,11 +24,13 @@ import static com.google.common.truth.Truth.assertThat; import static io.vertx.json.schema.common.dsl.Schemas.intSchema; -import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; -import static io.vertx.tests.MockHelper.mockParameter; import static io.vertx.openapi.contract.Location.PATH; import static io.vertx.openapi.contract.Style.LABEL; +import static io.vertx.openapi.validation.ValidationContext.REQUEST; +import static io.vertx.openapi.validation.ValidationContext.RESPONSE; import static io.vertx.openapi.validation.ValidatorErrorType.INVALID_VALUE; +import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; +import static io.vertx.tests.MockHelper.mockParameter; class SchemaValidationExceptionTest { @@ -42,7 +44,8 @@ class SchemaValidationExceptionTest { private static final OutputUnit DUMMY_OUTPUT_UNIT = new OutputUnit("instanceLocation2", "absoluteKeywordLocation2", "keywordLocation2", "error2", OutputErrorType.MISSING_VALUE); - private static final OutputUnit DUMMY_OUTPUT_UNIT_INVALID = new OutputUnit("instanceLocation2", "absoluteKeywordLocation2", + private static final OutputUnit DUMMY_OUTPUT_UNIT_INVALID = new OutputUnit("instanceLocation2", + "absoluteKeywordLocation2", "keywordLocation2", "error2", OutputErrorType.INVALID_VALUE); @BeforeAll @@ -64,7 +67,7 @@ void testCreateInvalidValueParameter() { @Test void testCreateInvalidValueRequestBody() { - SchemaValidationException exception = SchemaValidationException.createInvalidValueRequestBody(DUMMY_OUTPUT_UNIT, + SchemaValidationException exception = SchemaValidationException.createInvalidValueBody(DUMMY_OUTPUT_UNIT, REQUEST, DUMMY_CAUSE); assertThat(exception.getOutputUnit()).isEqualTo(DUMMY_OUTPUT_UNIT); assertThat(exception.getCause()).isEqualTo(DUMMY_CAUSE); @@ -75,7 +78,7 @@ void testCreateInvalidValueRequestBody() { @Test void testCreateInvalidValueResponseBody() { - SchemaValidationException exception = SchemaValidationException.createInvalidValueResponseBody(DUMMY_OUTPUT_UNIT, + SchemaValidationException exception = SchemaValidationException.createInvalidValueBody(DUMMY_OUTPUT_UNIT, RESPONSE, DUMMY_CAUSE); assertThat(exception.getOutputUnit()).isEqualTo(DUMMY_OUTPUT_UNIT); assertThat(exception.getCause()).isEqualTo(DUMMY_CAUSE); @@ -108,8 +111,9 @@ void testCreateErrorFromOutputUnitType() { String excpectedMsg = "The value of the request body is missing. Reason: error at instanceLocation"; assertThat(exception).hasMessageThat().isEqualTo(excpectedMsg); - SchemaValidationException exception_invalid = SchemaValidationException.createErrorFromOutputUnitType(DUMMY_PARAMETER, - DUMMY_OUTPUT_UNIT_INVALID, DUMMY_CAUSE); + SchemaValidationException exception_invalid = + SchemaValidationException.createErrorFromOutputUnitType(DUMMY_PARAMETER, + DUMMY_OUTPUT_UNIT_INVALID, DUMMY_CAUSE); assertThat(exception_invalid.getOutputUnit()).isEqualTo(DUMMY_OUTPUT_UNIT_INVALID); assertThat(exception_invalid.getCause()).isEqualTo(DUMMY_CAUSE); diff --git a/src/test/java/io/vertx/tests/validation/ValidationContextTest.java b/src/test/java/io/vertx/tests/validation/ValidationContextTest.java new file mode 100644 index 00000000..0781f56b --- /dev/null +++ b/src/test/java/io/vertx/tests/validation/ValidationContextTest.java @@ -0,0 +1,27 @@ +/* + * 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; + +import io.vertx.openapi.validation.ValidationContext; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; + +public class ValidationContextTest { + + @Test + public void testValidationContext() { + assertThat(ValidationContext.REQUEST.toString()).isEqualTo("request"); + assertThat(ValidationContext.RESPONSE.toString()).isEqualTo("response"); + } +} diff --git a/src/test/java/io/vertx/tests/validation/analyser/ApplicationJsonAnalyserTest.java b/src/test/java/io/vertx/tests/validation/analyser/ApplicationJsonAnalyserTest.java new file mode 100644 index 00000000..4027f0ff --- /dev/null +++ b/src/test/java/io/vertx/tests/validation/analyser/ApplicationJsonAnalyserTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023, 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 io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import io.vertx.openapi.validation.ValidatorException; +import io.vertx.openapi.validation.analyser.ApplicationJsonAnalyser; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; +import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; +import static io.vertx.openapi.validation.ValidationContext.REQUEST; +import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ApplicationJsonAnalyserTest { + + @Test + void testTransform() { + JsonObject dummyBody = new JsonObject().put("foo", "bar"); + ApplicationJsonAnalyser analyser = new ApplicationJsonAnalyser(APPLICATION_JSON.toString(), dummyBody.toBuffer(), + REQUEST); + + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + assertThat(analyser.transform()).isEqualTo(dummyBody); + } + + @Test + void testCheckSyntacticalCorrectnessThrows() { + ApplicationJsonAnalyser analyser = new ApplicationJsonAnalyser(APPLICATION_JSON.toString(), Buffer.buffer( + "\"foobar"), REQUEST); + + ValidatorException exception = assertThrows(ValidatorException.class, () -> analyser.checkSyntacticalCorrectness()); + String expectedMsg = "The request body can't be decoded"; + assertThat(exception.type()).isEqualTo(ILLEGAL_VALUE); + assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); + } +} diff --git a/src/test/java/io/vertx/tests/validation/analyser/ContentAnalyserTest.java b/src/test/java/io/vertx/tests/validation/analyser/ContentAnalyserTest.java new file mode 100644 index 00000000..16cb4d8b --- /dev/null +++ b/src/test/java/io/vertx/tests/validation/analyser/ContentAnalyserTest.java @@ -0,0 +1,46 @@ +/* + * 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 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; + +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; + +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/java/io/vertx/tests/validation/transformer/MultipartFormTransformerTest.java b/src/test/java/io/vertx/tests/validation/analyser/MultipartFormAnalyserTest.java similarity index 55% rename from src/test/java/io/vertx/tests/validation/transformer/MultipartFormTransformerTest.java rename to src/test/java/io/vertx/tests/validation/analyser/MultipartFormAnalyserTest.java index 71d930b5..5585e99d 100644 --- a/src/test/java/io/vertx/tests/validation/transformer/MultipartFormTransformerTest.java +++ b/src/test/java/io/vertx/tests/validation/analyser/MultipartFormAnalyserTest.java @@ -10,14 +10,13 @@ * */ -package io.vertx.tests.validation.transformer; +package io.vertx.tests.validation.analyser; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; -import io.vertx.openapi.validation.ValidatableRequest; import io.vertx.openapi.validation.ValidatorErrorType; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.transformer.MultipartFormTransformer; +import io.vertx.openapi.validation.analyser.MultipartFormAnalyser; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -30,13 +29,13 @@ import java.util.stream.Stream; import static com.google.common.truth.Truth.assertThat; -import static io.vertx.tests.MockHelper.mockValidatableRequest; +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 io.vertx.openapi.validation.transformer.MultipartFormTransformer.extractBoundary; import static org.junit.jupiter.api.Assertions.assertThrows; -class MultipartFormTransformerTest { - private static final Path TEST_RESOURCE_PATH = getRelatedTestResourcePath(MultipartFormTransformerTest.class); +class MultipartFormAnalyserTest { + private static final Path TEST_RESOURCE_PATH = getRelatedTestResourcePath(MultipartFormAnalyserTest.class); @Test void testExtractBoundary() { @@ -48,32 +47,32 @@ void testExtractBoundary() { assertThat(extractBoundary("multipart/form-data")).isNull(); } - static Stream testTransformThrowIfContentTypeisMissing() { + static Stream testCheckSyntacticalCorrectnessThrowIfContentTypeisMissing() { return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of("application/json")); } @ParameterizedTest @MethodSource - void testTransformThrowIfContentTypeisMissing(String contentType) { + void testCheckSyntacticalCorrectnessThrowIfContentTypeisMissing(String contentType) { ValidatorException exception = - assertThrows(ValidatorException.class, () -> MultipartFormTransformer.transform(null, null, contentType, - "request")); + assertThrows(ValidatorException.class, + () -> new MultipartFormAnalyser(contentType, null, REQUEST).checkSyntacticalCorrectness()); String expectedMsg = "The expected multipart/form-data request doesn't contain the required content-type header."; assertThat(exception.type()).isEqualTo(ValidatorErrorType.MISSING_REQUIRED_PARAMETER); assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); } - static Stream testTransformThrowIfBoundaryIsMissing() { + static Stream testCheckSyntacticalCorrectnessThrowIfBoundaryIsMissing() { return Stream.of(Arguments.of("multipart/form-data;"), Arguments.of("multipart/form-data; boundary= ")); } @ParameterizedTest @MethodSource - void testTransformThrowIfBoundaryIsMissing(String contentType) { + void testCheckSyntacticalCorrectnessThrowIfBoundaryIsMissing(String contentType) { ValidatorException exception = - assertThrows(ValidatorException.class, () -> MultipartFormTransformer.transform(null, null, contentType, - "request")); + assertThrows(ValidatorException.class, + () -> new MultipartFormAnalyser(contentType, null, REQUEST).checkSyntacticalCorrectness()); String expectedMsg = "The expected multipart/form-data request doesn't contain the required boundary information."; assertThat(exception.type()).isEqualTo(ValidatorErrorType.MISSING_REQUIRED_PARAMETER); @@ -82,42 +81,60 @@ void testTransformThrowIfBoundaryIsMissing(String contentType) { @ParameterizedTest @ValueSource(strings = {"multipart.txt", "multipart_extended_content_type.txt"}) - void testTransformRequest(String file) throws IOException { + void testTransform(String file) throws IOException { Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve(file))); - ValidatableRequest req = mockValidatableRequest(multipartBody, "multipart/form-data; boundary=abcde12345"); - + String contentType = "multipart/form-data; boundary=abcde12345"; JsonObject expected = new JsonObject() .put("id", "123e4567-e89b-12d3-a456-426655440000") .put("address", new JsonObject() .put("street", "3, Garden St") .put("city", "Hillsbery, UT")); - JsonObject json = (JsonObject) new MultipartFormTransformer().transformRequest(null, req); - assertThat(json).isEqualTo(expected); + MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST); + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + + assertThat((JsonObject) analyser.transform()).isEqualTo(expected); } @Test - void testTransformRequestContinueWhenBodyEmpty() throws IOException { - Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve("multipart_id_no_body.txt"))); - ValidatableRequest req = mockValidatableRequest(multipartBody, "multipart/form-data; boundary=abcde12345"); + void testTransformOctetStream() throws IOException { + Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve("multipart_octet_stream.txt"))); + String contentType = "multipart/form-data; boundary=abcde12345"; + JsonObject expected = new JsonObject() + .put("street", "3, Garden St") + .put("city", "Hillsbery, UT"); + + MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST); + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + JsonObject result = (JsonObject) analyser.transform(); + assertThat(result.getBuffer("address").toJsonObject()).isEqualTo(expected); + } + + @Test + void testTransformContinueWhenBodyEmpty() throws IOException { + Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve("multipart_id_no_body.txt"))); + String contentType = "multipart/form-data; boundary=abcde12345"; JsonObject expected = new JsonObject() .put("address", new JsonObject() .put("street", "3, Garden St") .put("city", "Hillsbery, UT")); - JsonObject json = (JsonObject) new MultipartFormTransformer().transformRequest(null, req); - assertThat(json).isEqualTo(expected); + MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST); + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + + assertThat((JsonObject) analyser.transform()).isEqualTo(expected); } @Test - void testTransformRequestPartWithInvalidContentType() throws IOException { + void testTransformPartWithInvalidContentType() throws IOException { Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve( "multipart_part_invalid_contenttype.txt"))); - ValidatableRequest req = mockValidatableRequest(multipartBody, "multipart/form-data; boundary=abcde12345"); + String contentType = "multipart/form-data; boundary=abcde12345"; - ValidatorException exception = - assertThrows(ValidatorException.class, () -> new MultipartFormTransformer().transformRequest(null, req)); + MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST); + analyser.checkSyntacticalCorrectness(); // must always be executed before transform + ValidatorException exception = assertThrows(ValidatorException.class, () -> analyser.transform()); String expectedMsg = "The content type text/html of property id is not yet supported."; assertThat(exception.type()).isEqualTo(ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT); diff --git a/src/test/java/io/vertx/tests/validation/transformer/MultipartPartTest.java b/src/test/java/io/vertx/tests/validation/analyser/MultipartPartTest.java similarity index 97% rename from src/test/java/io/vertx/tests/validation/transformer/MultipartPartTest.java rename to src/test/java/io/vertx/tests/validation/analyser/MultipartPartTest.java index d68e52f5..f96438e1 100644 --- a/src/test/java/io/vertx/tests/validation/transformer/MultipartPartTest.java +++ b/src/test/java/io/vertx/tests/validation/analyser/MultipartPartTest.java @@ -10,14 +10,14 @@ * */ -package io.vertx.tests.validation.transformer; +package io.vertx.tests.validation.analyser; import com.google.common.truth.Truth; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.openapi.validation.ValidatorErrorType; import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.transformer.MultipartPart; +import io.vertx.openapi.validation.analyser.MultipartPart; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; 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 88051c12..c4bbfff0 100644 --- a/src/test/java/io/vertx/tests/validation/impl/BaseValidatorTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/BaseValidatorTest.java @@ -12,32 +12,41 @@ package io.vertx.tests.validation.impl; +import io.vertx.core.Future; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.junit5.Timeout; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; +import io.vertx.openapi.contract.MediaType; import io.vertx.openapi.contract.OpenAPIContract; +import io.vertx.openapi.contract.Operation; +import io.vertx.openapi.contract.impl.MediaTypeImpl; +import io.vertx.openapi.validation.ValidationContext; import io.vertx.openapi.validation.ValidatorException; import io.vertx.openapi.validation.impl.BaseValidator; +import io.vertx.openapi.validation.impl.RequestParameterImpl; import org.junit.jupiter.api.BeforeEach; 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.ValueSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.nio.file.Path; import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Stream; 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 org.mockito.Mockito.spy; @ExtendWith(VertxExtension.class) class BaseValidatorTest { - private BaseValidator validator; + private BaseValidatorWrapper validator; - private OpenAPIContract contractSpy; @BeforeEach @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) @@ -45,8 +54,7 @@ void initializeContract(Vertx vertx, VertxTestContext testContext) { Path contractFile = TEST_RESOURCE_PATH.resolve("v3.1").resolve("petstore.json"); JsonObject contract = vertx.fileSystem().readFileBlocking(contractFile.toString()).toJsonObject(); OpenAPIContract.from(vertx, contract).onSuccess(c -> testContext.verify(() -> { - this.contractSpy = spy(c); - this.validator = new BaseValidator(vertx, contractSpy); + this.validator = new BaseValidatorWrapper(vertx, c); testContext.completeNow(); })).onFailure(testContext::failNow); } @@ -54,6 +62,17 @@ void initializeContract(Vertx vertx, VertxTestContext testContext) { @Test @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) void testGetOperation(VertxTestContext testContext) { + String operationId = "listPets"; + validator.getOperation(operationId).onFailure(testContext::failNow) + .onSuccess(operation -> testContext.verify(() -> { + assertThat(operation.getOperationId()).isEqualTo(operationId); + testContext.completeNow(); + })); + } + + @Test + @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) + void testGetOperationThrow(VertxTestContext testContext) { validator.getOperation("invalidId").onFailure(t -> testContext.verify(() -> { assertThat(t).isInstanceOf(ValidatorException.class); assertThat(t).hasMessageThat().isEqualTo("Invalid OperationId: invalidId"); @@ -61,10 +80,56 @@ void testGetOperation(VertxTestContext testContext) { })).onSuccess(v -> testContext.failNow("Test expects a failure")); } - @ParameterizedTest(name = "{index} Test valid if {0} is a valid base transformer.") - @ValueSource(strings = { "application/json", "application/hal+json" }) - public void testValidBaseTransformer(String transformer) { - assertThat(validator.containsTransformer(transformer)).isTrue(); + static Stream testIsSchemaValidationRequired() { + JsonObject stringSchema = new JsonObject().put("type", "string"); + 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 typeStringFormatTime = new MediaTypeImpl("", buildMediaModel.apply(stringSchema.copy().put("format", + "time"))); + MediaType typeStringFormatBinaryMinLength = new MediaTypeImpl("", + buildMediaModel.apply(binaryStringSchema.copy().put("minLength", 1))); + + return Stream.of( + Arguments.of("No media model is defined", noMediaModel, false), + Arguments.of("Type number", typeNumber, true), + Arguments.of("Type String without format", typeStringNoFormat, true), + Arguments.of("Type String and format binary", typeStringFormatBinary, false), + Arguments.of("Type String and format time", typeStringFormatTime, true), + Arguments.of("Type String and format binary but minLength", typeStringFormatBinaryMinLength, true) + ); } + @ParameterizedTest(name = "{index} {0}") + @MethodSource + void testIsSchemaValidationRequired(String scenario, MediaType mediaType, boolean isRequired) { + assertThat(validator.isSchemaValidationRequired(mediaType)).isEqualTo(isRequired); + } + + private class BaseValidatorWrapper extends BaseValidator { + + public BaseValidatorWrapper(Vertx vertx, OpenAPIContract contract) { + super(vertx, contract); + } + + @Override + protected Future getOperation(String operationId) { + return super.getOperation(operationId); + } + + @Override + protected boolean isSchemaValidationRequired(MediaType mediaType) { + return super.isSchemaValidationRequired(mediaType); + } + + @Override + protected RequestParameterImpl validate(MediaType mediaType, String contentType, Buffer rawContent, + ValidationContext requestOrResponse) { + return super.validate(mediaType, contentType, rawContent, requestOrResponse); + } + } } 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 c9818b5d..1b7f1e5d 100644 --- a/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java @@ -63,8 +63,6 @@ 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.tests.MockHelper.mockParameter; -import static io.vertx.tests.ResourceHelper.TEST_RESOURCE_PATH; import static io.vertx.openapi.contract.Location.COOKIE; import static io.vertx.openapi.contract.Location.HEADER; import static io.vertx.openapi.contract.Location.PATH; @@ -74,6 +72,8 @@ import static io.vertx.openapi.validation.ValidatorErrorType.INVALID_VALUE; import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT; +import static io.vertx.tests.MockHelper.mockParameter; +import static io.vertx.tests.ResourceHelper.TEST_RESOURCE_PATH; import static java.util.Collections.emptyList; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; @@ -117,10 +117,18 @@ private static Stream testValidateWithValidatableRequestAndOperationI when(mockedMediaType.getIdentifier()).thenReturn(MediaType.APPLICATION_JSON); when(mockedMediaType.getSchema()).thenReturn(JsonSchema.of(objectSchema().toJson())); + MediaType mockedMediaTypeBinary = mock(MediaType.class); + when(mockedMediaTypeBinary.getIdentifier()).thenReturn("application/octet-stream"); + when(mockedMediaTypeBinary.getSchema()).thenReturn(null); + RequestBody mockedRequestBody = mock(RequestBody.class); when(mockedRequestBody.isRequired()).thenReturn(true); when(mockedRequestBody.determineContentType(anyString())).thenReturn(mockedMediaType); + RequestBody mockedRequestBodyBinary = mock(RequestBody.class); + when(mockedRequestBodyBinary.isRequired()).thenReturn(true); + when(mockedRequestBodyBinary.determineContentType(anyString())).thenReturn(mockedMediaTypeBinary); + JsonObject body = new JsonObject().put("foo", "bar"); Map expectedCookies = @@ -150,8 +158,13 @@ private static Stream testValidateWithValidatableRequestAndOperationI new RequestParameterImpl(body.toBuffer()), APPLICATION_JSON.toString()); + ValidatedRequest expectedBinaryBody = + new ValidatedRequestImpl(expectedCookies, expectedHeaderParameters, expectedPathParameters, expectedQuery, + new RequestParameterImpl(body.toBuffer())); + return Stream.of( - Arguments.of(parameters, mockedRequestBody, request, expected) + Arguments.of(parameters, mockedRequestBody, request, expected), + Arguments.of(parameters, mockedRequestBodyBinary, request, expectedBinaryBody) ); } @@ -414,9 +427,11 @@ void testValidateBodyNotRequiredAndBodyIsNullOrEmpty(String scenario, RequestPar @ParameterizedTest(name = "validateBody should throw an error if MediaType or Transformer is null") @ValueSource(strings = {"text/plain", "foo/bar"}) - void testValidateBodyMediaTypeOrTransformerNull(String contentType) { - RequestBody mockedRequestBody = mockRequestBody(false, mock(MediaType.class)); + void testValidateBodyMediaTypeOrAnalyserNull(String contentType) { + MediaType mockedMediaType = mock(MediaType.class); + when(mockedMediaType.getIdentifier()).thenReturn(contentType); + RequestBody mockedRequestBody = mockRequestBody(false, mockedMediaType); ValidatableRequest mockedValidatableRequest = mock(ValidatableRequest.class); when(mockedValidatableRequest.getBody()).thenReturn(new RequestParameterImpl("foobar")); when(mockedValidatableRequest.getContentType()).thenReturn(contentType); 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 6f50bf1b..c3e2124f 100644 --- a/src/test/java/io/vertx/tests/validation/impl/ResponseValidatorImplTest.java +++ b/src/test/java/io/vertx/tests/validation/impl/ResponseValidatorImplTest.java @@ -54,13 +54,13 @@ 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.tests.MockHelper.mockParameter; -import static io.vertx.tests.ResourceHelper.TEST_RESOURCE_PATH; 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; import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER; import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT; +import static io.vertx.tests.MockHelper.mockParameter; +import static io.vertx.tests.ResourceHelper.TEST_RESOURCE_PATH; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; @@ -86,6 +86,7 @@ private static Stream provideNullRequestParameters() { private static Response mockResponse() { MediaType mockedMediaType = mock(MediaType.class); when(mockedMediaType.getSchema()).thenReturn(JsonSchema.of(objectSchema().toJson())); + when(mockedMediaType.getIdentifier()).thenReturn(APPLICATION_JSON.toString()); Response mockedResponse = mock(Response.class); when(mockedResponse.getContent()).thenReturn(ImmutableMap.of(APPLICATION_JSON.toString(), mockedMediaType)); @@ -239,9 +240,12 @@ void testValidateBodyRequiredButNullOrEmpty(String scenario, ResponseParameter p @ParameterizedTest(name = "validateBody should throw an error if MediaType or Transformer is null") @ValueSource(strings = {"text/plain", "foo/bar"}) - void testValidateBodyMediaTypeOrTransformerNull(String contentType) { + void testValidateBodyMediaTypeOrAnalyserNull(String contentType) { + MediaType mockedMediaType = mock(MediaType.class); + when(mockedMediaType.getIdentifier()).thenReturn(contentType); + Response mockedResponse = mock(Response.class); - when(mockedResponse.getContent()).thenReturn(ImmutableMap.of(TEXT_PLAIN.toString(), mock(MediaType.class))); + when(mockedResponse.getContent()).thenReturn(ImmutableMap.of(TEXT_PLAIN.toString(), mockedMediaType)); ValidatableResponse mockedValidatableResponse = mock(ValidatableResponse.class); when(mockedValidatableResponse.getContentType()).thenReturn(contentType); diff --git a/src/test/java/io/vertx/tests/validation/transformer/ApplicationJsonTransformerTest.java b/src/test/java/io/vertx/tests/validation/transformer/ApplicationJsonTransformerTest.java deleted file mode 100644 index cc0ebbd0..00000000 --- a/src/test/java/io/vertx/tests/validation/transformer/ApplicationJsonTransformerTest.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2023, 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.transformer; - -import io.vertx.core.buffer.Buffer; -import io.vertx.core.json.JsonObject; -import io.vertx.openapi.validation.ValidatableRequest; -import io.vertx.openapi.validation.ValidatableResponse; -import io.vertx.openapi.validation.ValidatorException; -import io.vertx.openapi.validation.transformer.ApplicationJsonTransformer; -import io.vertx.openapi.validation.transformer.BodyTransformer; -import org.junit.jupiter.api.Test; - -import static com.google.common.truth.Truth.assertThat; -import static io.netty.handler.codec.http.HttpHeaderValues.APPLICATION_JSON; -import static io.vertx.tests.MockHelper.mockValidatableRequest; -import static io.vertx.openapi.validation.ValidatorErrorType.ILLEGAL_VALUE; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ApplicationJsonTransformerTest { - private final BodyTransformer transformer = new ApplicationJsonTransformer(); - - @Test - void testTransformRequest() { - JsonObject dummyBody = new JsonObject().put("foo", "bar"); - ValidatableRequest request = mockValidatableRequest(dummyBody.toBuffer(), APPLICATION_JSON.toString()); - assertThat(transformer.transformRequest(null, request)).isEqualTo(dummyBody); - } - - @Test - void testTransformRequestThrows() { - ValidatorException exception = - assertThrows(ValidatorException.class, () -> transformer.transformRequest(null, - mockValidatableRequest(Buffer.buffer("\"foobar"), APPLICATION_JSON.toString()))); - String expectedMsg = "The request body can't be decoded"; - assertThat(exception.type()).isEqualTo(ILLEGAL_VALUE); - assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); - } - - @Test - void testTransformResponse() { - JsonObject dummyBody = new JsonObject().put("foo", "bar"); - ValidatableResponse response = ValidatableResponse.create(200, dummyBody.toBuffer(), APPLICATION_JSON.toString()); - assertThat(transformer.transformResponse(null, response)).isEqualTo(dummyBody); - } - - @Test - void testTransformResponseThrows() { - ValidatableResponse response = ValidatableResponse.create(200, Buffer.buffer("\"foobar"), - APPLICATION_JSON.toString()); - - ValidatorException exception = - assertThrows(ValidatorException.class, () -> transformer.transformResponse(null, response)); - String expectedMsg = "The request body can't be decoded"; - assertThat(exception.type()).isEqualTo(ILLEGAL_VALUE); - assertThat(exception).hasMessageThat().isEqualTo(expectedMsg); - } - - -} diff --git a/src/test/resources/io/vertx/tests/validation/analyser/.gitattributes b/src/test/resources/io/vertx/tests/validation/analyser/.gitattributes new file mode 100644 index 00000000..e69824a4 --- /dev/null +++ b/src/test/resources/io/vertx/tests/validation/analyser/.gitattributes @@ -0,0 +1 @@ +*.txt eol=crlf diff --git a/src/test/resources/io/vertx/tests/validation/transformer/multipart.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart.txt similarity index 95% rename from src/test/resources/io/vertx/tests/validation/transformer/multipart.txt rename to src/test/resources/io/vertx/tests/validation/analyser/multipart.txt index 62311494..1672329c 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/multipart.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart.txt @@ -1,14 +1,14 @@ ---abcde12345 -Content-Disposition: form-data; name="id" -Content-Type: text/plain - -123e4567-e89b-12d3-a456-426655440000 ---abcde12345 -Content-Disposition: form-data; name="address" -Content-Type: application/json - -{ - "street" : "3, Garden St", - "city" : "Hillsbery, UT" -} +--abcde12345 +Content-Disposition: form-data; name="id" +Content-Type: text/plain + +123e4567-e89b-12d3-a456-426655440000 +--abcde12345 +Content-Disposition: form-data; name="address" +Content-Type: application/json + +{ + "street" : "3, Garden St", + "city" : "Hillsbery, UT" +} --abcde12345-- \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/multipart_extended_content_type.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart_extended_content_type.txt similarity index 96% rename from src/test/resources/io/vertx/tests/validation/transformer/multipart_extended_content_type.txt rename to src/test/resources/io/vertx/tests/validation/analyser/multipart_extended_content_type.txt index f0a1c1ed..4980209c 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/multipart_extended_content_type.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart_extended_content_type.txt @@ -1,14 +1,14 @@ ---abcde12345 -Content-Disposition: form-data; name="id" -Content-Type: text/plain; charset=UTF-8 - -123e4567-e89b-12d3-a456-426655440000 ---abcde12345 -Content-Disposition: form-data; name="address" -Content-Type: application/json; charset=UTF-8 - -{ - "street" : "3, Garden St", - "city" : "Hillsbery, UT" -} +--abcde12345 +Content-Disposition: form-data; name="id" +Content-Type: text/plain; charset=UTF-8 + +123e4567-e89b-12d3-a456-426655440000 +--abcde12345 +Content-Disposition: form-data; name="address" +Content-Type: application/json; charset=UTF-8 + +{ + "street" : "3, Garden St", + "city" : "Hillsbery, UT" +} --abcde12345-- \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/multipart_id_no_body.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart_id_no_body.txt similarity index 95% rename from src/test/resources/io/vertx/tests/validation/transformer/multipart_id_no_body.txt rename to src/test/resources/io/vertx/tests/validation/analyser/multipart_id_no_body.txt index f3b32d80..157c0de6 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/multipart_id_no_body.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart_id_no_body.txt @@ -1,14 +1,14 @@ ---abcde12345 -Content-Disposition: form-data; name="id" -Content-Type: text/plain - - ---abcde12345 -Content-Disposition: form-data; name="address" -Content-Type: application/json - -{ - "street" : "3, Garden St", - "city" : "Hillsbery, UT" -} +--abcde12345 +Content-Disposition: form-data; name="id" +Content-Type: text/plain + + +--abcde12345 +Content-Disposition: form-data; name="address" +Content-Type: application/json + +{ + "street" : "3, Garden St", + "city" : "Hillsbery, UT" +} --abcde12345-- \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/multipart_invalid_structure.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure.txt similarity index 96% rename from src/test/resources/io/vertx/tests/validation/transformer/multipart_invalid_structure.txt rename to src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure.txt index 1c28ef79..9b66fac3 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/multipart_invalid_structure.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure.txt @@ -1,4 +1,4 @@ -Content-Length: 428 -Content-Type: multipart/form-data; boundary=abcde12345 - +Content-Length: 428 +Content-Type: multipart/form-data; boundary=abcde12345 + --abcde12345-- \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/multipart_invalid_structure_2.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure_2.txt similarity index 95% rename from src/test/resources/io/vertx/tests/validation/transformer/multipart_invalid_structure_2.txt rename to src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure_2.txt index 1de029af..447ceb5d 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/multipart_invalid_structure_2.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart_invalid_structure_2.txt @@ -1,13 +1,13 @@ ---abcde12345 -Content-Disposition: form-data; name="id" -Content-Type: text/plain - -123e4567-e89b-12d3-a456-426655440000 ---abcde12345 -Content-Disposition: form-data; name="address" -Content-Type: application/json - -{ - "street": "3, Garden St", - "city": "Hillsbery, UT" +--abcde12345 +Content-Disposition: form-data; name="id" +Content-Type: text/plain + +123e4567-e89b-12d3-a456-426655440000 +--abcde12345 +Content-Disposition: form-data; name="address" +Content-Type: application/json + +{ + "street": "3, Garden St", + "city": "Hillsbery, UT" } \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/analyser/multipart_octet_stream.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart_octet_stream.txt new file mode 100644 index 00000000..3906e47a --- /dev/null +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart_octet_stream.txt @@ -0,0 +1,9 @@ +--abcde12345 +Content-Disposition: form-data; name="address" +Content-Type: application/octet-stream + +{ + "street" : "3, Garden St", + "city" : "Hillsbery, UT" +} +--abcde12345-- \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/multipart_part_invalid_contenttype.txt b/src/test/resources/io/vertx/tests/validation/analyser/multipart_part_invalid_contenttype.txt similarity index 95% rename from src/test/resources/io/vertx/tests/validation/transformer/multipart_part_invalid_contenttype.txt rename to src/test/resources/io/vertx/tests/validation/analyser/multipart_part_invalid_contenttype.txt index baeb314d..044d51b0 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/multipart_part_invalid_contenttype.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/multipart_part_invalid_contenttype.txt @@ -1,14 +1,14 @@ ---abcde12345 -Content-Disposition: form-data; name="id" -Content-Type: text/html - -123e4567-e89b-12d3-a456-426655440000 ---abcde12345 -Content-Disposition: form-data; name="address" -Content-Type: application/json - -{ - "street" : "3, Garden St", - "city" : "Hillsbery, UT" -} +--abcde12345 +Content-Disposition: form-data; name="id" +Content-Type: text/html + +123e4567-e89b-12d3-a456-426655440000 +--abcde12345 +Content-Disposition: form-data; name="address" +Content-Type: application/json + +{ + "street" : "3, Garden St", + "city" : "Hillsbery, UT" +} --abcde12345-- \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/part1.txt b/src/test/resources/io/vertx/tests/validation/analyser/part1.txt similarity index 97% rename from src/test/resources/io/vertx/tests/validation/transformer/part1.txt rename to src/test/resources/io/vertx/tests/validation/analyser/part1.txt index a7e41db3..c2b13bb5 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/part1.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/part1.txt @@ -1,4 +1,4 @@ -Content-Disposition: form-data; name="id" -Content-Type: text/plain - +Content-Disposition: form-data; name="id" +Content-Type: text/plain + 123e4567-e89b-12d3-a456-426655440000 \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/part2.txt b/src/test/resources/io/vertx/tests/validation/analyser/part2.txt similarity index 95% rename from src/test/resources/io/vertx/tests/validation/transformer/part2.txt rename to src/test/resources/io/vertx/tests/validation/analyser/part2.txt index 63b559e1..66ed995e 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/part2.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/part2.txt @@ -1,7 +1,7 @@ -Content-Disposition: form-data; name="address" -Content-Type: application/json - -{ - "street" : "3, Garden St", - "city" : "Hillsbery, UT" +Content-Disposition: form-data; name="address" +Content-Type: application/json + +{ + "street" : "3, Garden St", + "city" : "Hillsbery, UT" } \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/part3.txt b/src/test/resources/io/vertx/tests/validation/analyser/part3.txt similarity index 97% rename from src/test/resources/io/vertx/tests/validation/transformer/part3.txt rename to src/test/resources/io/vertx/tests/validation/analyser/part3.txt index 8f118025..ea00d9bd 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/part3.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/part3.txt @@ -1,6 +1,6 @@ -content-disposition: form-data; name="randomBinary"; filename="random.bin" -content-length: 10 -content-type: application/octet-stream -content-transfer-encoding: binary - +content-disposition: form-data; name="randomBinary"; filename="random.bin" +content-length: 10 +content-type: application/octet-stream +content-transfer-encoding: binary + 9*‘4±Ê-›å \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/part_without_body.txt b/src/test/resources/io/vertx/tests/validation/analyser/part_without_body.txt similarity index 98% rename from src/test/resources/io/vertx/tests/validation/transformer/part_without_body.txt rename to src/test/resources/io/vertx/tests/validation/analyser/part_without_body.txt index b917e517..9bf41a2d 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/part_without_body.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/part_without_body.txt @@ -1,2 +1,2 @@ -Content-Disposition: form-data; name="id" +Content-Disposition: form-data; name="id" Content-Type: text/plain \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/part_without_contenttype.txt b/src/test/resources/io/vertx/tests/validation/analyser/part_without_contenttype.txt similarity index 96% rename from src/test/resources/io/vertx/tests/validation/transformer/part_without_contenttype.txt rename to src/test/resources/io/vertx/tests/validation/analyser/part_without_contenttype.txt index 0d587bde..2a70f02a 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/part_without_contenttype.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/part_without_contenttype.txt @@ -1,4 +1,4 @@ -Content-Disposition: form-data; name="id" -Content-Type: - +Content-Disposition: form-data; name="id" +Content-Type: + 123e4567-e89b-12d3-a456-426655440000 \ No newline at end of file diff --git a/src/test/resources/io/vertx/tests/validation/transformer/part_without_name.txt b/src/test/resources/io/vertx/tests/validation/analyser/part_without_name.txt similarity index 96% rename from src/test/resources/io/vertx/tests/validation/transformer/part_without_name.txt rename to src/test/resources/io/vertx/tests/validation/analyser/part_without_name.txt index baedb931..445b3901 100644 --- a/src/test/resources/io/vertx/tests/validation/transformer/part_without_name.txt +++ b/src/test/resources/io/vertx/tests/validation/analyser/part_without_name.txt @@ -1,4 +1,4 @@ -Content-Disposition: form-data; -Content-Type: text/plain - +Content-Disposition: form-data; +Content-Type: text/plain + 123e4567-e89b-12d3-a456-426655440000 \ No newline at end of file