From 344af1b6a617c2ebe50284866f5d3730ac05caae Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Tue, 11 Jun 2024 07:21:00 +0200 Subject: [PATCH 1/7] fix(#1175): OpenAPI connector enhancements for simulator random message generation --- connectors/citrus-openapi/pom.xml | 6 +- .../openapi/OpenApiRepository.java | 66 +++++++ .../openapi/OpenApiSpecification.java | 39 ++-- .../OpenApiSpecificationProcessor.java | 59 +++++++ .../openapi/OpenApiTestDataGenerator.java | 52 +++++- .../actions/OpenApiClientActionBuilder.java | 9 +- .../OpenApiClientResponseActionBuilder.java | 134 ++++++++++---- .../OpenApiServerRequestActionBuilder.java | 166 +++++++++++++----- .../OpenApiServerResponseActionBuilder.java | 56 +++--- .../openapi/model/OasModelHelper.java | 159 ++++++++++++++--- .../openapi/model/v2/Oas20ModelHelper.java | 113 +++++++++++- .../openapi/model/v3/Oas30ModelHelper.java | 119 ++++++++----- .../openapi/OpenApiRepositoryTest.java | 50 ++++++ .../openapi/SampleOpenApiProcessor.java | 30 ++++ .../model/v2/Oas20ModelHelperTest.java | 119 +++++++++++++ .../model/v3/Oas30ModelHelperTest.java | 126 +++++++++++-- .../openapi/processor/sampleOpenApiProcessor | 2 + .../spi/ReferenceResolverAware.java | 4 +- .../repository/BaseRepository.java | 107 +++++++++++ .../org/citrusframework/util/StringUtils.java | 21 +++ .../citrusframework/util/StringUtilsTest.java | 36 ++++ .../json/JsonSchemaRepository.java | 69 ++------ .../json/schema/JsonSchemaValidationTest.java | 14 +- .../xml/XsdSchemaRepository.java | 84 ++------- 24 files changed, 1307 insertions(+), 333 deletions(-) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java create mode 100644 connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor create mode 100644 core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java create mode 100644 core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java diff --git a/connectors/citrus-openapi/pom.xml b/connectors/citrus-openapi/pom.xml index bf85267d78..da92c9c032 100644 --- a/connectors/citrus-openapi/pom.xml +++ b/connectors/citrus-openapi/pom.xml @@ -41,11 +41,15 @@ ${project.version} provided - io.apicurio apicurio-data-models + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.17.0 + diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java new file mode 100644 index 0000000000..75b62e59c7 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java @@ -0,0 +1,66 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.ArrayList; +import java.util.List; +import org.citrusframework.repository.BaseRepository; +import org.citrusframework.spi.Resource; + +/** + * OpenApi repository holding a set of {@link OpenApiSpecification} known in the test scope. + * @since 4.4.0 + */ +public class OpenApiRepository extends BaseRepository { + + private static final String DEFAULT_NAME = "openApiSchemaRepository"; + + /** List of schema resources */ + private final List openApiSpecifications = new ArrayList<>(); + + + /** An optional context path, used for each api, without taking into account any {@link OpenApiSpecification} specific context path. */ + private String rootContextPath; + + public OpenApiRepository() { + super(DEFAULT_NAME); + } + + public String getRootContextPath() { + return rootContextPath; + } + + public void setRootContextPath(String rootContextPath) { + this.rootContextPath = rootContextPath; + } + + @Override + public void addRepository(Resource openApiResource) { + + OpenApiSpecification openApiSpecification = OpenApiSpecification.from(openApiResource); + openApiSpecification.setRootContextPath(rootContextPath); + + this.openApiSpecifications.add(openApiSpecification); + + OpenApiSpecificationProcessor.lookup().values().forEach(processor -> processor.process(openApiSpecification)); + } + + public List getOpenApiSpecifications() { + return openApiSpecifications; + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java index 00c5a1c382..5c28f5e67a 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java @@ -34,12 +34,21 @@ */ public class OpenApiSpecification { + public static final String HTTPS = "https"; + public static final String HTTP = "http"; /** URL to load the OpenAPI specification */ private String specUrl; private String httpClient; private String requestUrl; + /** + * The optional root context path to which the OpenAPI is hooked. + * This path is prepended to the base path specified in the OpenAPI configuration. + * If no root context path is specified, only the base path and additional segments are used. + */ + private String rootContextPath; + private OasDocument openApiDoc; private boolean generateOptionalFields = true; @@ -56,7 +65,7 @@ public static OpenApiSpecification from(String specUrl) { public static OpenApiSpecification from(URL specUrl) { OpenApiSpecification specification = new OpenApiSpecification(); OasDocument openApiDoc; - if (specUrl.getProtocol().startsWith("https")) { + if (specUrl.getProtocol().startsWith(HTTPS)) { openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specUrl); } else { openApiDoc = OpenApiResourceLoader.fromWebResource(specUrl); @@ -76,11 +85,11 @@ public static OpenApiSpecification from(Resource resource) { specification.setOpenApiDoc(openApiDoc); String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) - .orElse(Collections.singletonList("http")) + .orElse(Collections.singletonList(HTTP)) .stream() - .filter(s -> s.equals("http") || s.equals("https")) + .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) .findFirst() - .orElse("http"); + .orElse(HTTP); specification.setSpecUrl(resource.getLocation()); specification.setRequestUrl(String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), OasModelHelper.getBasePath(openApiDoc))); @@ -102,17 +111,17 @@ public OasDocument getOpenApiDoc(TestContext context) { resolvedSpecUrl = requestUrl.endsWith("/") ? requestUrl + resolvedSpecUrl.substring(1) : requestUrl + resolvedSpecUrl; } else if (httpClient != null && context.getReferenceResolver().isResolvable(httpClient, HttpClient.class)) { String baseUrl = context.getReferenceResolver().resolve(httpClient, HttpClient.class).getEndpointConfiguration().getRequestUrl(); - resolvedSpecUrl = baseUrl.endsWith("/") ? baseUrl + resolvedSpecUrl.substring(1) : baseUrl + resolvedSpecUrl;; + resolvedSpecUrl = baseUrl.endsWith("/") ? baseUrl + resolvedSpecUrl.substring(1) : baseUrl + resolvedSpecUrl; } else { throw new CitrusRuntimeException(("Failed to resolve OpenAPI spec URL from relative path %s - " + "make sure to provide a proper base URL when using relative paths").formatted(resolvedSpecUrl)); } } - if (resolvedSpecUrl.startsWith("http")) { + if (resolvedSpecUrl.startsWith(HTTP)) { try { URL specWebResource = new URL(resolvedSpecUrl); - if (resolvedSpecUrl.startsWith("https")) { + if (resolvedSpecUrl.startsWith(HTTPS)) { openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specWebResource); } else { openApiDoc = OpenApiResourceLoader.fromWebResource(specWebResource); @@ -129,11 +138,11 @@ public OasDocument getOpenApiDoc(TestContext context) { if (requestUrl == null) { String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) - .orElse(Collections.singletonList("http")) + .orElse(Collections.singletonList(HTTP)) .stream() - .filter(s -> s.equals("http") || s.equals("https")) + .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) .findFirst() - .orElse("http"); + .orElse(HTTP); setRequestUrl(String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), OasModelHelper.getBasePath(openApiDoc))); } @@ -190,4 +199,14 @@ public boolean isValidateOptionalFields() { public void setValidateOptionalFields(boolean validateOptionalFields) { this.validateOptionalFields = validateOptionalFields; } + + public String getRootContextPath() { + return rootContextPath; + } + + public void setRootContextPath(String rootContextPath) { + this.rootContextPath = rootContextPath; + } + + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java new file mode 100644 index 0000000000..b7ca0b5bdc --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java @@ -0,0 +1,59 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.Map; +import org.citrusframework.spi.ResourcePathTypeResolver; +import org.citrusframework.spi.TypeResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Interface for processing OpenAPI specifications. + *

+ * This interface is designed to be implemented by custom processors that handle OpenAPI specifications. + * Implementations of this interface are discovered by the standard citrus SPI mechanism. + *

+ */ +public interface OpenApiSpecificationProcessor { + + /** Logger */ + Logger logger = LoggerFactory.getLogger(OpenApiSpecificationProcessor.class); + + /** OpenAPI processors resource lookup path */ + String RESOURCE_PATH = "META-INF/citrus/openapi/processor"; + + /** Type resolver to find OpenAPI processors on classpath via resource path lookup */ + TypeResolver TYPE_RESOLVER = new ResourcePathTypeResolver(RESOURCE_PATH); + + void process(OpenApiSpecification openApiSpecification); + + /** + * Resolves all available processors from resource path lookup. Scans classpath for processors meta information + * and instantiates those processors. + */ + static Map lookup() { + Map processors = TYPE_RESOLVER.resolveAll("", TypeResolver.DEFAULT_TYPE_PROPERTY, "name"); + + if (logger.isDebugEnabled()) { + processors.forEach((k, v) -> logger.debug(String.format("Found openapi specification processor '%s' as %s", k, v.getClass()))); + } + + return processors; + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java index c240fe80c2..6dac0a6072 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java @@ -16,12 +16,12 @@ package org.citrusframework.openapi; +import io.apicurio.datamodels.openapi.models.OasSchema; import java.util.Map; import java.util.stream.Collectors; - -import io.apicurio.datamodels.openapi.models.OasSchema; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.openapi.model.OasModelHelper; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -349,4 +349,52 @@ public static String createRandomValueExpression(OasSchema schema) { return ""; } } + + /** + * Create validation expression using regex according to schema type and format. + * @param name + * @param oasSchema + * @return + */ + public static String createValidationRegex(String name, OasSchema oasSchema) { + + if (oasSchema != null && (OasModelHelper.isReferenceType(oasSchema) || OasModelHelper.isObjectType(oasSchema))) { + throw new CitrusRuntimeException(String.format("Unable to create a validation regex for an reference of object schema '%s'!", name)); + } + + return createValidationRegex(oasSchema); + } + + public static String createValidationRegex(OasSchema schema) { + + if (schema == null) { + return ""; + } + + switch (schema.type) { + case "string": + if (schema.format != null && schema.format.equals("date")) { + return "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])"; + } else if (schema.format != null && schema.format.equals("date-time")) { + return "\\d{4}-\\d{2}-\\d{2}T[01]\\d:[0-5]\\d:[0-5]\\d"; + } else if (StringUtils.hasText(schema.pattern)) { + return schema.pattern; + } else if (!CollectionUtils.isEmpty(schema.enum_)) { + return "(" + (String.join("|", schema.enum_)) + ")"; + } else if (schema.format != null && schema.format.equals("uuid")){ + return "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; + } else { + return ".*"; + } + case "number": + return "[0-9]+\\.?[0-9]*"; + case "integer": + return "[0-9]+"; + case "boolean": + return "(true|false)"; + default: + return ""; + } + } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java index 3d26d4d349..d6cfe3abc1 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java @@ -16,6 +16,7 @@ package org.citrusframework.openapi.actions; +import jakarta.annotation.Nullable; import org.citrusframework.TestAction; import org.citrusframework.TestActionBuilder; import org.citrusframework.endpoint.Endpoint; @@ -135,12 +136,12 @@ public TestActionBuilder getDelegate() { * @param referenceResolver */ @Override - public void setReferenceResolver(ReferenceResolver referenceResolver) { - if (referenceResolver == null) { + public void setReferenceResolver(@Nullable ReferenceResolver referenceResolver) { + if (referenceResolver != null) { this.referenceResolver = referenceResolver; - if (delegate instanceof ReferenceResolverAware) { - ((ReferenceResolverAware) delegate).setReferenceResolver(referenceResolver); + if (delegate instanceof ReferenceResolverAware referenceResolverAware) { + referenceResolverAware.setReferenceResolver(referenceResolver); } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java index 1e235868af..29b4d7e036 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java @@ -16,15 +16,16 @@ package org.citrusframework.openapi.actions; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; - import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasPathItem; import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; +import jakarta.annotation.Nullable; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -32,11 +33,14 @@ import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageBuilder; import org.citrusframework.message.Message; +import org.citrusframework.message.MessageType; import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.OpenApiTestDataGenerator; import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.util.StringUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -47,13 +51,78 @@ public class OpenApiClientResponseActionBuilder extends HttpClientResponseAction /** * Default constructor initializes http response message builder. */ - public OpenApiClientResponseActionBuilder(OpenApiSpecification openApiSpec, String operationId, String statusCode) { + public OpenApiClientResponseActionBuilder(OpenApiSpecification openApiSpec, String operationId, + String statusCode) { this(new HttpMessage(), openApiSpec, operationId, statusCode); } - public OpenApiClientResponseActionBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, - String operationId, String statusCode) { - super(new OpenApiClientResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode), httpMessage); + public OpenApiClientResponseActionBuilder(HttpMessage httpMessage, + OpenApiSpecification openApiSpec, + String operationId, String statusCode) { + super(new OpenApiClientResponseMessageBuilder(httpMessage, openApiSpec, operationId, + statusCode), httpMessage); + } + + public static void fillMessageFromResponse(OpenApiSpecification openApiSpecification, + TestContext context, HttpMessage httpMessage, @Nullable OasOperation operation, + @Nullable OasResponse response) { + + if (operation == null || response == null) { + return; + } + + fillRequiredHeaders( + openApiSpecification, context, httpMessage, response); + + Optional responseSchema = OasModelHelper.getSchema(response); + responseSchema.ifPresent(oasSchema -> { + httpMessage.setPayload( + OpenApiTestDataGenerator.createInboundPayload(oasSchema, + OasModelHelper.getSchemaDefinitions( + openApiSpecification.getOpenApiDoc(context)), openApiSpecification)); + + // Best guess for the content type. Currently, we can only determine the content type + // for sure for json. Other content types will be neglected. + OasSchema resolvedSchema = OasModelHelper.resolveSchema( + openApiSpecification.getOpenApiDoc(null), oasSchema); + if (OasModelHelper.isObjectType(resolvedSchema) || OasModelHelper.isObjectArrayType( + resolvedSchema)) { + Collection responseTypes = OasModelHelper.getResponseTypes(operation, + response); + if (responseTypes.contains(MediaType.APPLICATION_JSON_VALUE)) { + httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE); + httpMessage.setType(MessageType.JSON); + } + } + } + ); + } + + private static void fillRequiredHeaders( + OpenApiSpecification openApiSpecification, TestContext context, HttpMessage httpMessage, + OasResponse response) { + + Map requiredHeaders = OasModelHelper.getRequiredHeaders(response); + for (Map.Entry header : requiredHeaders.entrySet()) { + httpMessage.setHeader(header.getKey(), + OpenApiTestDataGenerator.createValidationExpression(header.getKey(), + header.getValue(), + OasModelHelper.getSchemaDefinitions( + openApiSpecification.getOpenApiDoc(context)), false, + openApiSpecification, + context)); + } + + Map headers = OasModelHelper.getHeaders(response); + for (Map.Entry header : headers.entrySet()) { + if (!requiredHeaders.containsKey(header.getKey()) && context.getVariables() + .containsKey(header.getKey())) { + httpMessage.setHeader(header.getKey(), + CitrusSettings.VARIABLE_PREFIX + header.getKey() + + CitrusSettings.VARIABLE_SUFFIX); + } + } } private static class OpenApiClientResponseMessageBuilder extends HttpMessageBuilder { @@ -64,8 +133,9 @@ private static class OpenApiClientResponseMessageBuilder extends HttpMessageBuil private final HttpMessage httpMessage; - public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, - String operationId, String statusCode) { + public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage, + OpenApiSpecification openApiSpec, + String operationId, String statusCode) { super(httpMessage); this.openApiSpec = openApiSpec; this.operationId = operationId; @@ -79,9 +149,10 @@ public Message build(TestContext context, String messageType) { OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); for (OasPathItem path : OasModelHelper.getPathItems(oasDocument.paths)) { - Optional> operationEntry = OasModelHelper.getOperationMap(path).entrySet().stream() - .filter(op -> operationId.equals(op.getValue().operationId)) - .findFirst(); + Optional> operationEntry = OasModelHelper.getOperationMap( + path).entrySet().stream() + .filter(op -> operationId.equals(op.getValue().operationId)) + .findFirst(); if (operationEntry.isPresent()) { operation = operationEntry.get().getValue(); @@ -90,36 +161,27 @@ public Message build(TestContext context, String messageType) { } if (operation == null) { - throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); + throw new CitrusRuntimeException( + "Unable to locate operation with id '%s' in OpenAPI specification %s".formatted( + operationId, openApiSpec.getSpecUrl())); } if (operation.responses != null) { - OasResponse response = Optional.ofNullable(operation.responses.getItem(statusCode)) - .orElse(operation.responses.default_); + OasResponse response; - if (response != null) { - Map requiredHeaders = OasModelHelper.getRequiredHeaders(response); - for (Map.Entry header : requiredHeaders.entrySet()) { - httpMessage.setHeader(header.getKey(), OpenApiTestDataGenerator.createValidationExpression(header.getKey(), header.getValue(), - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context)); - } - - Map headers = OasModelHelper.getHeaders(response); - for (Map.Entry header : headers.entrySet()) { - if (!requiredHeaders.containsKey(header.getKey()) && context.getVariables().containsKey(header.getKey())) { - httpMessage.setHeader(header.getKey(), CitrusSettings.VARIABLE_PREFIX + header.getKey() + CitrusSettings.VARIABLE_SUFFIX); - } - } - - Optional responseSchema = OasModelHelper.getSchema(response); - responseSchema.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec))); + if (StringUtils.hasText(statusCode)) { + response = Optional.ofNullable(operation.responses.getItem(statusCode)) + .orElse(operation.responses.default_); + } else { + response = OasModelHelper.getResponseForRandomGeneration( + openApiSpec.getOpenApiDoc(null), operation) + .orElse(operation.responses.default_); } - } - OasModelHelper.getResponseContentType(oasDocument, operation) - .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); + fillMessageFromResponse(openApiSpec, context, httpMessage, operation, response); + } - if (Pattern.compile("[0-9]+").matcher(statusCode).matches()) { + if (Pattern.compile("\\d+").matcher(statusCode).matches()) { httpMessage.status(HttpStatus.valueOf(Integer.parseInt(statusCode))); } else { httpMessage.status(HttpStatus.OK); diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java index 97b1f3ec06..bdd5a98c95 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java @@ -16,17 +16,25 @@ package org.citrusframework.openapi.actions; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; +import static java.lang.String.format; +import static org.citrusframework.message.MessageType.JSON; +import static org.citrusframework.message.MessageType.PLAINTEXT; +import static org.citrusframework.message.MessageType.XML; +import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType; +import static org.citrusframework.util.StringUtils.appendSegmentToPath; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; import io.apicurio.datamodels.openapi.models.OasPathItem; import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -75,6 +83,24 @@ public OpenApiServerRequestMessageBuilder(HttpMessage httpMessage, OpenApiSpecif @Override public Message build(TestContext context, String messageType) { + OasOperationParams oasOperationParams = getResult(context); + + if (oasOperationParams.operation() == null) { + throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); + } + + setSpecifiedMessageType(oasOperationParams); + setSpecifiedHeaders(context, oasOperationParams); + setSpecifiedQueryParameters(context, oasOperationParams); + setSpecifiedPath(context, oasOperationParams); + setSpecifiedBody(oasOperationParams); + setSpecifiedRequestContentType(oasOperationParams); + setSpecifiedMethod(oasOperationParams); + + return super.build(context, messageType); + } + + private OasOperationParams getResult(TestContext context) { OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); OasOperation operation = null; OasPathItem pathItem = null; @@ -92,58 +118,110 @@ public Message build(TestContext context, String messageType) { break; } } + return new OasOperationParams(oasDocument, operation, pathItem, method); + } - if (operation == null) { - throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); - } + private void setSpecifiedRequestContentType(OasOperationParams oasOperationParams) { + OasModelHelper.getRequestContentType(oasOperationParams.operation) + .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, String.format("@startsWith(%s)@", contentType))); + } + + private void setSpecifiedPath(TestContext context, OasOperationParams oasOperationParams) { + String randomizedPath = OasModelHelper.getBasePath(oasOperationParams.oasDocument) + oasOperationParams.pathItem.getPath(); + randomizedPath = randomizedPath.replace("//", "/"); + + randomizedPath = appendSegmentToPath(openApiSpec.getRootContextPath(), randomizedPath); - if (operation.parameters != null) { - operation.parameters.stream() - .filter(param -> "header".equals(param.in)) - .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) - .forEach(param -> httpMessage.setHeader(param.getName(), - OpenApiTestDataGenerator.createValidationExpression(param.getName(), (OasSchema) param.schema, - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context))); - - operation.parameters.stream() - .filter(param -> "query".equals(param.in)) - .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) - .forEach(param -> httpMessage.queryParam(param.getName(), - OpenApiTestDataGenerator.createValidationExpression(param.getName(), (OasSchema) param.schema, - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context))); + if (oasOperationParams.operation.parameters != null) { + randomizedPath = determinePath(context, oasOperationParams.operation, randomizedPath); } - Optional body = OasModelHelper.getRequestBodySchema(oasDocument, operation); - body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec))); + httpMessage.path(randomizedPath); + } - String randomizedPath = OasModelHelper.getBasePath(oasDocument) + pathItem.getPath(); - randomizedPath = randomizedPath.replaceAll("//", "/"); + private void setSpecifiedBody(OasOperationParams oasOperationParams) { + Optional body = OasModelHelper.getRequestBodySchema(oasOperationParams.oasDocument, oasOperationParams.operation); + body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions( + oasOperationParams.oasDocument), openApiSpec))); + } - if (operation.parameters != null) { - List pathParams = operation.parameters.stream() - .filter(p -> "path".equals(p.in)).toList(); + private String determinePath(TestContext context, OasOperation operation, + String randomizedPath) { + List pathParams = operation.parameters.stream() + .filter(p -> "path".equals(p.in)).toList(); - for (OasParameter parameter : pathParams) { - String parameterValue; - if (context.getVariables().containsKey(parameter.getName())) { - parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + CitrusSettings.VARIABLE_SUFFIX; - } else { - parameterValue = OpenApiTestDataGenerator.createValidationExpression((OasSchema) parameter.schema, - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec); - } + for (OasParameter parameter : pathParams) { + String parameterValue; + if (context.getVariables().containsKey(parameter.getName())) { + parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + CitrusSettings.VARIABLE_SUFFIX; randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") - .matcher(randomizedPath) - .replaceAll(parameterValue); + .matcher(randomizedPath) + .replaceAll(parameterValue); + } else { + parameterValue = OpenApiTestDataGenerator.createValidationRegex(parameter.getName(), OasModelHelper.getParameterSchema(parameter).orElse(null)); + + randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") + .matcher(randomizedPath) + .replaceAll(parameterValue); + + randomizedPath = format("@matches('%s')@", randomizedPath); } } + return randomizedPath; + } - OasModelHelper.getRequestContentType(operation) - .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, String.format("@startsWith(%s)@", contentType))); + private void setSpecifiedQueryParameters(TestContext context, OasOperationParams oasOperationParams) { - httpMessage.path(randomizedPath); - httpMessage.method(method); + if (oasOperationParams.operation.parameters == null) { + return; + } + + oasOperationParams.operation.parameters.stream() + .filter(param -> "query".equals(param.in)) + .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) + .forEach(param -> httpMessage.queryParam(param.getName(), + OpenApiTestDataGenerator.createValidationExpression(param.getName(), OasModelHelper.getParameterSchema(param).orElse(null), + OasModelHelper.getSchemaDefinitions(oasOperationParams.oasDocument), false, openApiSpec, + context))); - return super.build(context, messageType); } + + private void setSpecifiedHeaders(TestContext context, OasOperationParams oasOperationParams) { + + if (oasOperationParams.operation.parameters == null) { + return; + } + + oasOperationParams.operation.parameters.stream() + .filter(param -> "header".equals(param.in)) + .filter( + param -> (param.required != null && param.required) || context.getVariables() + .containsKey(param.getName())) + .forEach(param -> httpMessage.setHeader(param.getName(), + OpenApiTestDataGenerator.createValidationExpression(param.getName(), + OasModelHelper.getParameterSchema(param).orElse(null), + OasModelHelper.getSchemaDefinitions(oasOperationParams.oasDocument), false, openApiSpec, + context))); + } + + private void setSpecifiedMessageType(OasOperationParams oasOperationParams) { + Optional requestContentType = getRequestContentType( + oasOperationParams.operation); + if (requestContentType.isPresent() && APPLICATION_JSON_VALUE.equals(requestContentType.get())) { + httpMessage.setType(JSON); + } else if (requestContentType.isPresent() && APPLICATION_XML_VALUE.equals(requestContentType.get())) { + httpMessage.setType(XML); + } else { + httpMessage.setType(PLAINTEXT); + } + } + + private void setSpecifiedMethod(OasOperationParams oasOperationParams) { + httpMessage.method(oasOperationParams.method); + } + + } + + private record OasOperationParams(OasDocument oasDocument, OasOperation operation, OasPathItem pathItem, HttpMethod method) { } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java index 2c4a67c100..36273da2a4 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java @@ -95,34 +95,13 @@ public Message build(TestContext context, String messageType) { } if (operation.responses != null) { - OasResponse response = Optional.ofNullable(operation.responses.getItem(statusCode)) - .orElse(operation.responses.default_); - - if (response != null) { - Map requiredHeaders = OasModelHelper.getRequiredHeaders(response); - for (Map.Entry header : requiredHeaders.entrySet()) { - httpMessage.setHeader(header.getKey(), - OpenApiTestDataGenerator.createRandomValueExpression(header.getKey(), header.getValue(), - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context)); - } - - Map headers = OasModelHelper.getHeaders(response); - for (Map.Entry header : headers.entrySet()) { - if (!requiredHeaders.containsKey(header.getKey()) && context.getVariables().containsKey(header.getKey())) { - httpMessage.setHeader(header.getKey(), CitrusSettings.VARIABLE_PREFIX + header.getKey() + CitrusSettings.VARIABLE_SUFFIX); - } - } - - Optional responseSchema = OasModelHelper.getSchema(response); - responseSchema.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, - OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec))); - } + buildResponse(context, operation, oasDocument); } - OasModelHelper.getResponseContentType(oasDocument, operation) + OasModelHelper.getResponseContentTypeForRandomGeneration(oasDocument, operation) .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); - if (Pattern.compile("[0-9]+").matcher(statusCode).matches()) { + if (Pattern.compile("\\d+").matcher(statusCode).matches()) { httpMessage.status(HttpStatus.valueOf(Integer.parseInt(statusCode))); } else { httpMessage.status(HttpStatus.OK); @@ -130,5 +109,34 @@ public Message build(TestContext context, String messageType) { return super.build(context, messageType); } + + private void buildResponse(TestContext context, OasOperation operation, + OasDocument oasDocument) { + OasResponse response = Optional.ofNullable(operation.responses.getItem(statusCode)) + .orElse(operation.responses.default_); + + if (response != null) { + Map requiredHeaders = OasModelHelper.getRequiredHeaders(response); + for (Map.Entry header : requiredHeaders.entrySet()) { + httpMessage.setHeader(header.getKey(), + OpenApiTestDataGenerator.createRandomValueExpression(header.getKey(), header.getValue(), + OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, + context)); + } + + Map headers = OasModelHelper.getHeaders(response); + for (Map.Entry header : headers.entrySet()) { + if (!requiredHeaders.containsKey(header.getKey()) && + context.getVariables().containsKey(header.getKey())) { + httpMessage.setHeader(header.getKey(), + CitrusSettings.VARIABLE_PREFIX + header.getKey() + CitrusSettings.VARIABLE_SUFFIX); + } + } + + Optional responseSchema = OasModelHelper.getSchema(response); + responseSchema.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, + OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec))); + } + } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java index e74259c828..f8d1e703cd 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java @@ -16,26 +16,31 @@ package org.citrusframework.openapi.model; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.BiFunction; -import java.util.function.Function; - import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasParameter; import io.apicurio.datamodels.openapi.models.OasPathItem; import io.apicurio.datamodels.openapi.models.OasPaths; import io.apicurio.datamodels.openapi.models.OasResponse; +import io.apicurio.datamodels.openapi.models.OasResponses; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v2.models.Oas20Document; import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; import org.citrusframework.openapi.model.v2.Oas20ModelHelper; import org.citrusframework.openapi.model.v3.Oas30ModelHelper; @@ -54,7 +59,7 @@ private OasModelHelper() { * @return true if given schema is an object. */ public static boolean isObjectType(OasSchema schema) { - return "object".equals(schema.type); + return schema != null && "object".equals(schema.type); } /** @@ -66,13 +71,32 @@ public static boolean isArrayType(OasSchema schema) { return "array".equals(schema.type); } + /** + * Determines if given schema is of type object array . + * @param schema to check + * @return true if given schema is an object array. + */ + public static boolean isObjectArrayType(OasSchema schema) { + if (schema == null || !"array".equals(schema.type)) { + return false; + } + Object items = schema.items; + if (items instanceof OasSchema oasSchema) { + return isObjectType(oasSchema); + } else if (items instanceof List list) { + return list.stream().allMatch(item -> item instanceof OasSchema oasSchema && isObjectType(oasSchema)); + } + + return false; + } + /** * Determines if given schema has a reference to another schema object. * @param schema to check * @return true if given schema has a reference. */ public static boolean isReferenceType(OasSchema schema) { - return schema.$ref != null; + return schema != null && schema.$ref != null; } public static String getHost(OasDocument openApiDoc) { @@ -83,6 +107,14 @@ public static List getSchemes(OasDocument openApiDoc) { return delegate(openApiDoc, Oas20ModelHelper::getSchemes, Oas30ModelHelper::getSchemes); } + public static OasSchema resolveSchema(OasDocument oasDocument, OasSchema schema) { + if (isReferenceType(schema)) { + return getSchemaDefinitions(oasDocument).get(schema.$ref); + } + + return schema; + } + public static String getBasePath(OasDocument openApiDoc) { return delegate(openApiDoc, Oas20ModelHelper::getBasePath, Oas30ModelHelper::getBasePath); } @@ -163,6 +195,10 @@ public static Optional getSchema(OasResponse response) { return delegate(response, Oas20ModelHelper::getSchema, Oas30ModelHelper::getSchema); } + public static Optional getParameterSchema(OasParameter parameter) { + return delegate(parameter, Oas20ModelHelper::getParameterSchema, Oas30ModelHelper::getParameterSchema); + } + public static Map getRequiredHeaders(OasResponse response) { return delegate(response, Oas20ModelHelper::getHeaders, Oas30ModelHelper::getRequiredHeaders); } @@ -179,8 +215,27 @@ public static Optional getRequestBodySchema(OasDocument openApiDoc, O return delegate(openApiDoc, operation, Oas20ModelHelper::getRequestBodySchema, Oas30ModelHelper::getRequestBodySchema); } - public static Optional getResponseContentType(OasDocument openApiDoc, OasOperation operation) { - return delegate(openApiDoc, operation, Oas20ModelHelper::getResponseContentType, Oas30ModelHelper::getResponseContentType); + public static Collection getResponseTypes(OasOperation operation, OasResponse response) { + return delegate(operation, response, Oas20ModelHelper::getResponseTypes, Oas30ModelHelper::getResponseTypes); + } + + /** + * Determines the appropriate response from an OAS (OpenAPI Specification) operation. + * The method looks for the response status code within the range 200 to 299 and returns + * the corresponding response if one is found. The first response in the list of responses, + * that satisfies the constraint will be returned. (TODO: see comment in Oas30ModelHelper) If none of the responses has a 2xx status code, + * the first response in the list will be returned. + * + */ + public static Optional getResponseForRandomGeneration(OasDocument openApiDoc, OasOperation operation) { + return delegate(openApiDoc, operation, Oas20ModelHelper::getResponseForRandomGeneration, Oas30ModelHelper::getResponseForRandomGeneration); + } + + /** + * Returns the response type used for random response generation. See specific helper implementations for detail. + */ + public static Optional getResponseContentTypeForRandomGeneration(OasDocument openApiDoc, OasOperation operation) { + return delegate(openApiDoc, operation, Oas20ModelHelper::getResponseContentTypeForRandomGeneration, Oas30ModelHelper::getResponseContentTypeForRandomGeneration); } /** @@ -210,15 +265,51 @@ private static T delegate(OasDocument openApiDoc, Function * @return */ private static T delegate(OasResponse response, Function oas20Function, Function oas30Function) { - if (response instanceof Oas20Response) { - return oas20Function.apply((Oas20Response) response); - } else if (response instanceof Oas30Response) { - return oas30Function.apply((Oas30Response) response); + if (response instanceof Oas20Response oas20Response) { + return oas20Function.apply(oas20Response); + } else if (response instanceof Oas30Response oas30Response) { + return oas30Function.apply(oas30Response); } throw new IllegalArgumentException(String.format("Unsupported operation response type: %s", response.getClass())); } + /** + * Delegate method to version specific model helpers for Open API v2 or v3. + * @param response + * @param oas20Function function to apply in case of v2 + * @param oas30Function function to apply in case of v3 + * @param generic return value + * @return + */ + private static T delegate(OasOperation operation, OasResponse response, BiFunction oas20Function, BiFunction oas30Function) { + if (operation instanceof Oas20Operation oas20Operation && response instanceof Oas20Response oas20Response) { + return oas20Function.apply(oas20Operation, oas20Response); + } else if (operation instanceof Oas30Operation oas30Operation && response instanceof Oas30Response oas30Response) { + return oas30Function.apply(oas30Operation, oas30Response); + } + + throw new IllegalArgumentException(String.format("Unsupported operation response type: %s", response.getClass())); + } + + /** + * Delegate method to version specific model helpers for Open API v2 or v3. + * @param parameter + * @param oas20Function function to apply in case of v2 + * @param oas30Function function to apply in case of v3 + * @param generic return value + * @return + */ + private static T delegate(OasParameter parameter, Function oas20Function, Function oas30Function) { + if (parameter instanceof Oas20Parameter oas20Parameter) { + return oas20Function.apply(oas20Parameter); + } else if (parameter instanceof Oas30Parameter oas30Parameter) { + return oas30Function.apply(oas30Parameter); + } + + throw new IllegalArgumentException(String.format("Unsupported operation parameter type: %s", parameter.getClass())); + } + /** * Delegate method to version specific model helpers for Open API v2 or v3. * @param operation @@ -228,10 +319,10 @@ private static T delegate(OasResponse response, Function o * @return */ private static T delegate(OasOperation operation, Function oas20Function, Function oas30Function) { - if (operation instanceof Oas20Operation) { - return oas20Function.apply((Oas20Operation) operation); - } else if (operation instanceof Oas30Operation) { - return oas30Function.apply((Oas30Operation) operation); + if (operation instanceof Oas20Operation oas20Operation) { + return oas20Function.apply(oas20Operation); + } else if (operation instanceof Oas30Operation oas30Operation) { + return oas30Function.apply(oas30Operation); } throw new IllegalArgumentException(String.format("Unsupported operation type: %s", operation.getClass())); @@ -262,4 +353,32 @@ private static boolean isOas30(OasDocument openApiDoc) { private static boolean isOas20(OasDocument openApiDoc) { return OpenApiVersion.fromDocumentType(openApiDoc).equals(OpenApiVersion.V2); } + + /** + * Resolves all responses in the given {@link OasResponses} instance using the provided {@code responseResolver} function. + * + *

This method iterates over the responses contained in the {@link OasResponses} object. If a response has a reference + * (indicated by a non-null {@code $ref} field), the reference is resolved using the {@code responseResolver} function. Other responses + * will be added to the result list as is.

+ * + * @param responses the {@link OasResponses} instance containing the responses to be resolved. + * @param responseResolver a {@link Function} that takes a reference string and returns the corresponding {@link OasResponse}. + * @return a {@link List} of {@link OasResponse} instances, where all references have been resolved. + */ + public static List resolveResponses(OasResponses responses, Function responseResolver) { + + List responseList = new ArrayList<>(); + for (OasResponse response : responses.getResponses()) { + if (response.$ref != null) { + OasResponse resolved = responseResolver.apply(getReferenceName(response.$ref)); + if (resolved != null) { + responseList.add(resolved); + } + } else { + responseList.add(response); + } + } + + return responseList; + } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java index 36d9596fd4..d3b83c4000 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java @@ -16,21 +16,26 @@ package org.citrusframework.openapi.model.v2; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - import io.apicurio.datamodels.openapi.models.OasHeader; import io.apicurio.datamodels.openapi.models.OasParameter; +import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v2.models.Oas20Document; import io.apicurio.datamodels.openapi.v2.models.Oas20Header; import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; import io.apicurio.datamodels.openapi.v2.models.Oas20SchemaDefinition; +import jakarta.annotation.Nullable; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.citrusframework.openapi.model.OasModelHelper; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -89,14 +94,60 @@ public static Optional getRequestContentType(Oas20Operation operation) { return Optional.empty(); } - public static Optional getResponseContentType(Oas20Document openApiDoc, Oas20Operation operation) { + public static Collection getResponseTypes(Oas20Operation operation, @Nullable Oas20Response response) { + if (operation == null) { + return Collections.emptyList(); + } + return operation.produces; + } + + /** + * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, + * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. + * + * @param openApiDoc + * @param operation + * @return + */ + public static Optional getResponseContentTypeForRandomGeneration(@Nullable Oas20Document openApiDoc, Oas20Operation operation) { if (operation.produces != null) { - return Optional.of(operation.produces.get(0)); + for (String mediaType : operation.produces) { + if (MediaType.APPLICATION_JSON_VALUE.equals(mediaType)) { + return Optional.of(mediaType); + } + } } return Optional.empty(); } + public static Optional getResponseForRandomGeneration(Oas20Document openApiDoc, Oas20Operation operation) { + + if (operation.responses == null) { + return Optional.empty(); + } + + List responses = OasModelHelper.resolveResponses(operation.responses, + responseRef -> openApiDoc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))); + + // Pick the response object related to the first 2xx return code found + Optional response = responses.stream() + .filter(Oas20Response.class::isInstance) + .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) + .map(OasResponse.class::cast) + .filter(res -> OasModelHelper.getSchema(res).isPresent()) + .findFirst(); + + if (response.isEmpty()) { + // TODO: Although the Swagger specification states that at least one successful response SHOULD be specified in the responses, + // the Petstore API does not. It only specifies error responses. As a result, we currently only return a successful response if one is found. + // If no successful response is specified, we return an empty response instead, to be backwards compatible. + response = Optional.empty(); + } + + return response; + } + public static Map getHeaders(Oas20Response response) { if (response.headers == null) { return Collections.emptyMap(); @@ -106,6 +157,13 @@ public static Map getHeaders(Oas20Response response) { .collect(Collectors.toMap(OasHeader::getName, Oas20ModelHelper::getHeaderSchema)); } + /** + * If the header already contains a schema (and it is an instance of {@link Oas20Header}), this schema is returned. + * Otherwise, a new {@link Oas20Header} is created based on the properties of the parameter and returned. + * + * @param header the {@link Oas20Header} from which to extract or create the schema + * @return an {@link Optional} containing the extracted or newly created {@link OasSchema} + */ private static OasSchema getHeaderSchema(Oas20Header header) { Oas20Schema schema = new Oas20Schema(); schema.title = header.getName(); @@ -132,4 +190,43 @@ private static OasSchema getHeaderSchema(Oas20Header header) { schema.exclusiveMinimum = header.exclusiveMinimum; return schema; } + + /** + * If the parameter already contains a schema (and it is an instance of {@link Oas20Schema}), this schema is returned. + * Otherwise, a new {@link Oas20Schema} is created based on the properties of the parameter and returned. + * + * @param parameter the {@link Oas20Parameter} from which to extract or create the schema + * @return an {@link Optional} containing the extracted or newly created {@link OasSchema} + */ + public static Optional getParameterSchema(Oas20Parameter parameter) { + if (parameter.schema instanceof Oas20Schema oasSchema) { + return Optional.of(oasSchema); + } + + Oas20Schema schema = new Oas20Schema(); + schema.title = parameter.getName(); + schema.type = parameter.type; + schema.format = parameter.format; + schema.items = parameter.items; + schema.multipleOf = parameter.multipleOf; + + schema.default_ = parameter.default_; + schema.enum_ = parameter.enum_; + + schema.pattern = parameter.pattern; + schema.description = parameter.description; + schema.uniqueItems = parameter.uniqueItems; + + schema.maximum = parameter.maximum; + schema.maxItems = parameter.maxItems; + schema.maxLength = parameter.maxLength; + schema.exclusiveMaximum = parameter.exclusiveMaximum; + + schema.minimum = parameter.minimum; + schema.minItems = parameter.minItems; + schema.minLength = parameter.minLength; + schema.exclusiveMinimum = parameter.exclusiveMinimum; + + return Optional.of(schema); + } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java index 39bdd2486e..0cdf5c9227 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java @@ -16,17 +16,6 @@ package org.citrusframework.openapi.model.v3; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - import io.apicurio.datamodels.core.models.common.Server; import io.apicurio.datamodels.core.models.common.ServerVariable; import io.apicurio.datamodels.openapi.models.OasResponse; @@ -34,11 +23,25 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType; import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import jakarta.annotation.Nullable; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; import org.citrusframework.openapi.model.OasModelHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -47,6 +50,7 @@ public final class Oas30ModelHelper { /** Logger */ private static final Logger LOG = LoggerFactory.getLogger(Oas30ModelHelper.class); + public static final String NO_URL_ERROR_MESSAGE = "Unable to determine base path from server URL: %s"; private Oas30ModelHelper() { // utility class @@ -62,7 +66,7 @@ public static String getHost(Oas30Document openApiDoc) { try { return new URL(serverUrl).getHost(); } catch (MalformedURLException e) { - throw new IllegalStateException(String.format("Unable to determine base path from server URL: %s", serverUrl)); + throw new IllegalStateException(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); } } @@ -80,12 +84,12 @@ public static List getSchemes(Oas30Document openApiDoc) { try { return new URL(serverUrl).getProtocol(); } catch (MalformedURLException e) { - LOG.warn(String.format("Unable to determine base path from server URL: %s", serverUrl)); + LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); return null; } }) .filter(Objects::nonNull) - .collect(Collectors.toList()); + .toList(); } public static String getBasePath(Oas30Document openApiDoc) { @@ -101,7 +105,7 @@ public static String getBasePath(Oas30Document openApiDoc) { try { basePath = new URL(serverUrl).getPath(); } catch (MalformedURLException e) { - throw new IllegalStateException(String.format("Unable to determine base path from server URL: %s", serverUrl)); + throw new IllegalStateException(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); } } else { basePath = serverUrl; @@ -117,7 +121,7 @@ public static Map getSchemaDefinitions(Oas30Document openApiD return openApiDoc.components.schemas.entrySet() .stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> (OasSchema) entry.getValue())); + .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue)); } public static Optional getSchema(Oas30Response response) { @@ -172,44 +176,69 @@ public static Optional getRequestContentType(Oas30Operation operation) { .findFirst(); } - public static Optional getResponseContentType(Oas30Document openApiDoc, Oas30Operation operation) { - if (operation.responses == null) { - return Optional.empty(); + public static Collection getResponseTypes(@Nullable Oas30Operation operation, Oas30Response response) { + if (operation == null) { + return Collections.emptySet(); } + return response.content != null ? response.content.keySet() : Collections.emptyList(); + } - List responses = new ArrayList<>(); + /** + * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, + * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. + * + * @param openApiDoc + * @param operation + * @return + */ + public static Optional getResponseContentTypeForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { + Optional responseForRandomGeneration = getResponseForRandomGeneration( + openApiDoc, operation); + return responseForRandomGeneration.map( + Oas30Response.class::cast).flatMap(res -> res.content.entrySet() + .stream() + .filter(entry -> MediaType.APPLICATION_JSON_VALUE.equals(entry.getKey())) + .filter(entry -> entry.getValue().schema != null) + .map(Map.Entry::getKey) + .findFirst()); + } - for (OasResponse response : operation.responses.getResponses()) { - if (response.$ref != null) { - responses.add(openApiDoc.components.responses.get(OasModelHelper.getReferenceName(response.$ref))); - } else { - responses.add(response); - } + public static Optional getResponseForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { + if (operation.responses == null) { + return Optional.empty(); } + List responses = OasModelHelper.resolveResponses(operation.responses, + responseRef -> openApiDoc.components.responses.get(OasModelHelper.getReferenceName(responseRef))); + // Pick the response object related to the first 2xx return code found - Optional response = responses.stream() - .filter(Oas30Response.class::isInstance) - .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) - .map(Oas30Response.class::cast) - .filter(res -> Oas30ModelHelper.getSchema(res).isPresent()) - .findFirst(); + Optional response = responses.stream() + .filter(Oas30Response.class::isInstance) + .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) + .map(OasResponse.class::cast) + .filter(res -> OasModelHelper.getSchema(res).isPresent()) + .findFirst(); // No 2xx response given so pick the first one no matter what status code - if (!response.isPresent()) { + if (response.isEmpty()) { + // TODO: This behavior differs from OAS2 and is very likely a bug because it may result in returning error messages. + // According to the specification, there MUST be at least one response, which SHOULD be a successful response. + // If the response is NOT A SUCCESSFUL one, we encounter an error case, which is likely not the intended behavior. + // The specification likely does not intend to define operations that always fail. On the other hand, it is not + // against the spec to NOT document an OK response that is empty. + // For testing purposes, note that the difference between OAS2 and OAS3 is evident in the Petstore API. + // The Petstore API specifies successful response codes for OAS3 but lacks these definitions for OAS2. + // Therefore, while tests pass for OAS3, they fail for OAS2. + // I would suggest to return an empty response in case we fail to resolve a good response, as in Oas2. + // In case of absence of a response an OK response will be sent as default. response = responses.stream() - .filter(Oas30Response.class::isInstance) - .map(Oas30Response.class::cast) - .filter(res -> Oas30ModelHelper.getSchema(res).isPresent()) - .findFirst(); + .filter(Oas30Response.class::isInstance) + .map(OasResponse.class::cast) + .filter(res -> OasModelHelper.getSchema(res).isPresent()) + .findFirst(); } - return response.flatMap(res -> res.content.entrySet() - .stream() - .filter(entry -> entry.getValue().schema != null) - .map(Map.Entry::getKey) - .findFirst()); - + return response; } public static Map getRequiredHeaders(Oas30Response response) { @@ -254,4 +283,8 @@ private static String resolveUrl(Server server) { return url; } + + public static Optional getParameterSchema(Oas30Parameter parameter) { + return Optional.ofNullable((OasSchema) parameter.schema); + } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java new file mode 100644 index 0000000000..3c449ff5fe --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java @@ -0,0 +1,50 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import java.util.List; +import org.testng.annotations.Test; + +@Test +public class OpenApiRepositoryTest { + + public static final String ROOT = "/root"; + + public void initializeOpenApiRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setRootContextPath(ROOT); + openApiRepository.setLocations(List.of("org/citrusframework/openapi/petstore/petstore**.json")); + openApiRepository.initialize(); + + List openApiSpecifications = openApiRepository.getOpenApiSpecifications(); + + assertEquals(openApiRepository.getRootContextPath(), ROOT); + assertNotNull(openApiSpecifications); + assertEquals(openApiSpecifications.size(),3); + + assertEquals(openApiSpecifications.get(0).getRootContextPath(), ROOT); + assertEquals(openApiSpecifications.get(1).getRootContextPath(), ROOT); + + assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(0))); + assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(1))); + assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(2))); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java new file mode 100644 index 0000000000..c39605e35e --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java @@ -0,0 +1,30 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.ArrayList; +import java.util.List; + +public class SampleOpenApiProcessor implements OpenApiSpecificationProcessor { + + public static List processedSpecifications = new ArrayList<>(); + + @Override + public void process(OpenApiSpecification openApiSpecification) { + processedSpecifications.add(openApiSpecification); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java new file mode 100644 index 0000000000..742641ad6c --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java @@ -0,0 +1,119 @@ +package org.citrusframework.openapi.model.v2; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasResponse; +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Document; +import io.apicurio.datamodels.openapi.v2.models.Oas20Items; +import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; +import io.apicurio.datamodels.openapi.v2.models.Oas20Response; +import io.apicurio.datamodels.openapi.v2.models.Oas20Responses; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import java.util.List; +import java.util.Optional; +import org.testng.annotations.Test; + +public class Oas20ModelHelperTest { + + @Test + public void shouldFindRandomResponse() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse = new Oas20Response("403"); + nokResponse.schema = new Oas20Schema(); + + Oas20Response okResponse = new Oas20Response("200"); + okResponse.schema = new Oas20Schema(); + + operation.responses = new Oas20Responses(); + operation.responses.addResponse("403", nokResponse); + operation.responses.addResponse("200", okResponse); + + Optional responseForRandomGeneration = Oas20ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(okResponse, responseForRandomGeneration.get()); + } + + @Test + public void shouldNotFindAnyResponse() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse403 = new Oas20Response("403"); + Oas20Response nokResponse407 = new Oas20Response("407"); + + operation.responses = new Oas20Responses(); + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = Oas20ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isEmpty()); + } + + @Test + public void shouldFindParameterSchema() { + Oas20Parameter parameter = new Oas20Parameter(); + parameter.schema = new Oas20Schema(); + + Optional parameterSchema = Oas20ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isPresent()); + assertEquals(parameter.schema, parameterSchema.get()); + } + + @Test + public void shouldFindSchemaFromParameter() { + Oas20Parameter parameter = new Oas20Parameter("testParameter"); + parameter.type = "string"; + parameter.format = "date-time"; + parameter.items = new Oas20Items(); + parameter.multipleOf = 2; + parameter.default_ = "defaultValue"; + parameter.enum_ = List.of("value1", "value2"); + parameter.pattern = "pattern"; + parameter.description = "description"; + parameter.uniqueItems = true; + parameter.maximum = 100.0; + parameter.maxItems = 10; + parameter.maxLength = 20; + parameter.exclusiveMaximum = true; + parameter.minimum = 0.0; + parameter.minItems = 1; + parameter.minLength = 5; + parameter.exclusiveMinimum = false; + + Optional schemaOptional = Oas20ModelHelper.getParameterSchema(parameter); + assertTrue(schemaOptional.isPresent()); + + OasSchema parameterSchema = schemaOptional.get(); + assertEquals(parameterSchema.title, "testParameter"); + assertEquals(parameterSchema.type, "string"); + assertEquals(parameterSchema.format, "date-time"); + assertEquals(parameter.items, parameterSchema.items); + assertEquals(parameter.multipleOf, parameterSchema.multipleOf); + assertEquals(parameter.default_, parameterSchema.default_); + assertEquals(parameter.enum_, parameterSchema.enum_); + assertEquals(parameter.pattern, parameterSchema.pattern); + assertEquals(parameter.description, parameterSchema.description); + assertEquals(parameter.uniqueItems, parameterSchema.uniqueItems); + assertEquals(parameter.maximum, parameterSchema.maximum); + assertEquals(parameter.maxItems, parameterSchema.maxItems); + assertEquals(parameter.maxLength, parameterSchema.maxLength); + assertEquals(parameter.exclusiveMaximum, parameterSchema.exclusiveMaximum); + assertEquals(parameter.minimum, parameterSchema.minimum); + assertEquals(parameter.minItems, parameterSchema.minItems); + assertEquals(parameter.minLength, parameterSchema.minLength); + assertEquals(parameter.exclusiveMinimum, parameterSchema.exclusiveMinimum); + + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java index 38d8b13fde..8735ecd083 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java @@ -1,13 +1,24 @@ package org.citrusframework.openapi.model.v3; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30Header; +import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import io.apicurio.datamodels.openapi.v3.models.Oas30Responses; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; -import org.testng.Assert; -import org.testng.annotations.Test; - +import java.util.Collection; import java.util.Map; +import java.util.Optional; +import org.springframework.http.MediaType; +import org.testng.annotations.Test; public class Oas30ModelHelperTest { @@ -15,40 +26,135 @@ public class Oas30ModelHelperTest { public void shouldNotFindRequiredHeadersWithoutRequiredAttribute() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = null; // explicitely assigned because this is test case + header.required = null; // explicitly assigned because this is test case var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 0); + assertEquals(result.size(), 0); } @Test public void shouldFindRequiredHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.TRUE; // explicitely assigned because this is test case + header.required = Boolean.TRUE; // explicitly assigned because this is test case var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 1); - Assert.assertSame(result.get(header.getName()), header.schema); + assertEquals(result.size(), 1); + assertSame(result.get(header.getName()), header.schema); } @Test public void shouldNotFindOptionalHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.FALSE; // explicitely assigned because this is test case + header.required = Boolean.FALSE; // explicitly assigned because this is test case var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 0); + assertEquals(result.size(), 0); + } + + @Test + public void shouldFindAllRequestTypesForOperation() { + Oas30Operation operation = new Oas30Operation("GET"); + operation.responses = new Oas30Responses(); + + Oas30Response response = new Oas30Response("200"); + response.content = Map.of(MediaType.APPLICATION_JSON_VALUE, + new Oas30MediaType(MediaType.APPLICATION_JSON_VALUE), + MediaType.APPLICATION_XML_VALUE, new Oas30MediaType(MediaType.APPLICATION_XML_VALUE)); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("200", response); + + Collection responseTypes = Oas30ModelHelper.getResponseTypes(operation, response); + + assertTrue(responseTypes.contains(MediaType.APPLICATION_JSON_VALUE)); + assertTrue(responseTypes.contains(MediaType.APPLICATION_XML_VALUE)); + + } + + @Test + public void shouldFindRandomResponse() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response okResponse = new Oas30Response("200"); + Oas30MediaType jsonMediaType = new Oas30MediaType(MediaType.APPLICATION_JSON_VALUE); + jsonMediaType.schema = new Oas30Schema(); + + Oas30MediaType xmlMediaType = new Oas30MediaType(MediaType.APPLICATION_XML_VALUE); + xmlMediaType.schema = new Oas30Schema(); + + okResponse.content = Map.of(MediaType.APPLICATION_JSON_VALUE, jsonMediaType, + MediaType.APPLICATION_XML_VALUE, xmlMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("403", nokResponse); + operation.responses.addResponse("200", okResponse); + + Optional responseForRandomGeneration = Oas30ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(okResponse, responseForRandomGeneration.get()); + } + + @Test + public void shouldFindAnyResponse() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse403 = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse403.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response nokResponse407 = new Oas30Response("407"); + nokResponse407.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = Oas30ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(nokResponse403, responseForRandomGeneration.get()); + } + + @Test + public void shouldFindParameterSchema() { + Oas30Parameter parameter = new Oas30Parameter(); + parameter.schema = new Oas30Schema(); + + Optional parameterSchema = Oas30ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isPresent()); + assertEquals(parameter.schema, parameterSchema.get()); + } + + @Test + public void shouldNotFindParameterSchema() { + Oas30Parameter parameter = new Oas30Parameter(); + + Optional parameterSchema = Oas30ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isEmpty()); } } \ No newline at end of file diff --git a/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor b/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor new file mode 100644 index 0000000000..1092e9da72 --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor @@ -0,0 +1,2 @@ +name=sampleOpenApiProcessor +type=org.citrusframework.openapi.SampleOpenApiProcessor diff --git a/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java b/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java index 1d4fafcf24..d65d697a9f 100644 --- a/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java +++ b/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java @@ -16,6 +16,8 @@ package org.citrusframework.spi; +import jakarta.annotation.Nullable; + /** * @author Christoph Deppisch */ @@ -26,5 +28,5 @@ public interface ReferenceResolverAware { * Sets the reference resolver. * @param referenceResolver */ - void setReferenceResolver(ReferenceResolver referenceResolver); + void setReferenceResolver(@Nullable ReferenceResolver referenceResolver); } diff --git a/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java b/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java new file mode 100644 index 0000000000..eae38431bd --- /dev/null +++ b/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java @@ -0,0 +1,107 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.repository; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.citrusframework.common.InitializingPhase; +import org.citrusframework.common.Named; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.spi.ClasspathResourceResolver; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources; +import org.citrusframework.util.FileUtils; +import org.citrusframework.util.StringUtils; + +/** + * Base class for repositories providing common functionality for initializing and managing resources. + * Implementations must provide the logic for loading and adding resources to the repository. + */ +public abstract class BaseRepository implements Named, InitializingPhase { + + private String name; + + /** List of location patterns that will be translated to schema resources */ + private List locations = new ArrayList<>(); + + protected BaseRepository(String name) { + this.name = name; + } + + @Override + public void initialize() { + try { + ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver(); + for (String location : locations) { + Resource found = Resources.create(location); + if (found.exists()) { + addRepository(found); + } else { + Set findings; + if (StringUtils.hasText(FileUtils.getFileExtension(location))) { + String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*"); + String basePath = FileUtils.getBasePath(location); + findings = resourceResolver.getResources(basePath, fileNamePattern); + } else { + findings = resourceResolver.getResources(location); + } + + for (Path resource : findings) { + addRepository(Resources.fromClasspath(resource.toString())); + } + } + } + } catch (IOException e) { + throw new CitrusRuntimeException("Failed to initialize repository", e); + } + } + + protected abstract void addRepository(Resource resource); + + @Override + public void setName(String name) { + this.name = name; + } + + /** + * Gets the name. + * @return the name to get. + */ + public String getName() { + return name; + } + + /** + * Gets the locations. + * @return the locations to get. + */ + public List getLocations() { + return locations; + } + + /** + * Sets the locations. + * @param locations the locations to set + */ + public void setLocations(List locations) { + this.locations = locations; + } + +} diff --git a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java index b1f208a21b..d99932ea55 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java +++ b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java @@ -42,4 +42,25 @@ public static boolean hasText(String str) { public static boolean isEmpty(String str) { return str == null || str.isEmpty(); } + + public static String appendSegmentToPath(String path, String segment) { + + if (path == null) { + return segment; + } + + if (segment == null) { + return path; + } + + if (!path.endsWith("/")) { + path = path +"/"; + } + + if (segment.startsWith("/")) { + segment = segment.substring(1); + } + + return path+segment; + } } diff --git a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java new file mode 100644 index 0000000000..e1b1bab770 --- /dev/null +++ b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java @@ -0,0 +1,36 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.util; + +import org.testng.Assert; +import org.testng.annotations.Test; + +public class StringUtilsTest { + + @Test + public void appendSegmentToPath() { + Assert.assertEquals(StringUtils.appendSegmentToPath("s1","s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("s1/","s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("s1/","/s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1","/s2"), "/s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/","/s2"), "/s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/","/s2/"), "/s1/s2/"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/",null), "/s1/"); + Assert.assertEquals(StringUtils.appendSegmentToPath(null,"/s2/"), "/s2/"); + Assert.assertNull(StringUtils.appendSegmentToPath(null,null)); + } +} diff --git a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java index 754bb63652..c3dfc33a37 100644 --- a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java +++ b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java @@ -16,21 +16,11 @@ package org.citrusframework.json; -import java.io.IOException; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Set; - -import org.citrusframework.common.InitializingPhase; -import org.citrusframework.common.Named; -import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.json.schema.SimpleJsonSchema; -import org.citrusframework.spi.ClasspathResourceResolver; +import org.citrusframework.repository.BaseRepository; import org.citrusframework.spi.Resource; -import org.citrusframework.spi.Resources; -import org.citrusframework.util.FileUtils; -import org.citrusframework.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,70 +28,35 @@ * Schema repository holding a set of json schema resources known in the test scope. * @since 2.7.3 */ -public class JsonSchemaRepository implements Named, InitializingPhase { +public class JsonSchemaRepository extends BaseRepository { - /** This repositories name in the Spring application context */ - private String name; + private static final String DEFAULT_NAME = "jsonSchemaRepository"; /** List of schema resources */ private List schemas = new ArrayList<>(); - /** List of location patterns that will be translated to schema resources */ - private List locations = new ArrayList<>(); /** Logger */ private static Logger logger = LoggerFactory.getLogger(JsonSchemaRepository.class); - @Override - public void setName(String name) { - this.name = name; + public JsonSchemaRepository() { + super(DEFAULT_NAME); } - @Override - public void initialize() { - try { - ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver(); - for (String location : locations) { - Resource found = Resources.create(location); - if (found.exists()) { - addSchemas(found); - } else { - Set findings; - if (StringUtils.hasText(FileUtils.getFileExtension(location))) { - String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*"); - String basePath = FileUtils.getBasePath(location); - findings = resourceResolver.getResources(basePath, fileNamePattern); - } else { - findings = resourceResolver.getResources(location); - } - - for (Path resource : findings) { - addSchemas(Resources.fromClasspath(resource.toString())); - } - } - } - } catch (IOException e) { - throw new CitrusRuntimeException("Failed to initialize Json schema repository", e); - } - } - private void addSchemas(Resource resource) { + protected void addRepository(Resource resource) { if (resource.getLocation().endsWith(".json")) { if (logger.isDebugEnabled()) { - logger.debug("Loading json schema resource " + resource.getLocation()); + logger.debug("Loading json schema resource '{}'", resource.getLocation()); } SimpleJsonSchema simpleJsonSchema = new SimpleJsonSchema(resource); simpleJsonSchema.initialize(); schemas.add(simpleJsonSchema); } else { - logger.warn("Skipped resource other than json schema for repository (" + resource.getLocation() + ")"); + logger.warn("Skipped resource other than json schema for repository '{}'", resource.getLocation()); } } - public String getName() { - return name; - } - public List getSchemas() { return schemas; } @@ -118,11 +73,7 @@ public static void setLog(Logger logger) { JsonSchemaRepository.logger = logger; } - public List getLocations() { - return locations; - } - - public void setLocations(List locations) { - this.locations = locations; + public void addSchema(SimpleJsonSchema simpleJsonSchema) { + schemas.add(simpleJsonSchema); } } diff --git a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java index 6a7ae17db9..90310c7d3b 100644 --- a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java +++ b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java @@ -34,6 +34,7 @@ import org.citrusframework.validation.json.report.GraciousProcessingReport; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -58,12 +59,21 @@ public class JsonSchemaValidationTest { private JsonSchemaValidation fixture; + private AutoCloseable mocks; + @BeforeMethod void beforeMethodSetup() { - MockitoAnnotations.openMocks(this); + mocks = MockitoAnnotations.openMocks(this); fixture = new JsonSchemaValidation(jsonSchemaFilterMock); } + @AfterMethod + void afterMethod() throws Exception { + if (mocks != null) { + mocks.close(); + } + } + @Test public void testValidJsonMessageSuccessfullyValidated() { // Setup json schema repositories @@ -264,7 +274,7 @@ public void testJsonSchemaFilterIsCalled() { @Test public void testLookup() { Map> validators = SchemaValidator.lookup(); - assertEquals(validators.size(), 1L); + assertEquals(1L, validators.size()); assertNotNull(validators.get("defaultJsonSchemaValidator")); assertEquals(validators.get("defaultJsonSchemaValidator").getClass(), JsonSchemaValidation.class); } diff --git a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java index e696c6316b..7ca3052066 100644 --- a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java +++ b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java @@ -17,20 +17,14 @@ package org.citrusframework.xml; import java.io.IOException; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Set; import javax.xml.parsers.ParserConfigurationException; - -import org.citrusframework.common.InitializingPhase; -import org.citrusframework.common.Named; import org.citrusframework.exceptions.CitrusRuntimeException; -import org.citrusframework.spi.ClasspathResourceResolver; +import org.citrusframework.repository.BaseRepository; import org.citrusframework.spi.Resource; import org.citrusframework.spi.Resources; import org.citrusframework.util.FileUtils; -import org.citrusframework.util.StringUtils; import org.citrusframework.xml.schema.TargetNamespaceSchemaMappingStrategy; import org.citrusframework.xml.schema.WsdlXsdSchema; import org.citrusframework.xml.schema.XsdSchemaMappingStrategy; @@ -48,22 +42,23 @@ * @author Christoph Deppisch */ @SuppressWarnings("unused") -public class XsdSchemaRepository implements Named, InitializingPhase { - /** The name of the repository */ - private String name = "schemaRepository"; +public class XsdSchemaRepository extends BaseRepository { + + private static final String DEFAULT_NAME = "schemaRepository"; /** List of schema resources */ private List schemas = new ArrayList<>(); - /** List of location patterns that will be translated to schema resources */ - private List locations = new ArrayList<>(); - /** Mapping strategy */ private XsdSchemaMappingStrategy schemaMappingStrategy = new TargetNamespaceSchemaMappingStrategy(); /** Logger */ private static final Logger logger = LoggerFactory.getLogger(XsdSchemaRepository.class); + public XsdSchemaRepository() { + super(DEFAULT_NAME); + } + /** * Find the matching schema for document using given schema mapping strategy. * @param doc the document instance to validate. @@ -76,28 +71,8 @@ public boolean canValidate(Document doc) { @Override public void initialize() { + super.initialize(); try { - ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver(); - for (String location : locations) { - Resource found = Resources.create(location); - if (found.exists()) { - addSchemas(found); - } else { - Set findings; - if (StringUtils.hasText(FileUtils.getFileExtension(location))) { - String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*"); - String basePath = FileUtils.getBasePath(location); - findings = resourceResolver.getResources(basePath, fileNamePattern); - } else { - findings = resourceResolver.getResources(location); - } - - for (Path resource : findings) { - addSchemas(Resources.fromClasspath(resource.toString())); - } - } - } - // Add default Citrus message schemas if available on classpath addCitrusSchema("citrus-http-message"); addCitrusSchema("citrus-mail-message"); @@ -105,7 +80,7 @@ public void initialize() { addCitrusSchema("citrus-ssh-message"); addCitrusSchema("citrus-rmi-message"); addCitrusSchema("citrus-jmx-message"); - } catch (SAXException | ParserConfigurationException | IOException e) { + } catch (SAXException | ParserConfigurationException e) { throw new CitrusRuntimeException("Failed to initialize Xsd schema repository", e); } } @@ -114,26 +89,26 @@ public void initialize() { * Adds Citrus message schema to repository if available on classpath. * @param schemaName The name of the schema within the citrus schema package */ - protected void addCitrusSchema(String schemaName) throws IOException, SAXException, ParserConfigurationException { + protected void addCitrusSchema(String schemaName) throws SAXException, ParserConfigurationException { Resource resource = Resources.fromClasspath("classpath:org/citrusframework/schema/" + schemaName + ".xsd"); if (resource.exists()) { addXsdSchema(resource); } } - private void addSchemas(Resource resource) { + protected void addRepository(Resource resource) { if (resource.getLocation().endsWith(".xsd")) { addXsdSchema(resource); } else if (resource.getLocation().endsWith(".wsdl")) { addWsdlSchema(resource); } else { - logger.warn("Skipped resource other than XSD schema for repository (" + resource.getLocation() + ")"); + logger.warn("Skipped resource other than XSD schema for repository '{}'", resource.getLocation()); } } private void addWsdlSchema(Resource resource) { if (logger.isDebugEnabled()) { - logger.debug("Loading WSDL schema resource " + resource.getLocation()); + logger.debug("Loading WSDL schema resource '{}'", resource.getLocation()); } WsdlXsdSchema wsdl = new WsdlXsdSchema(resource); @@ -143,7 +118,7 @@ private void addWsdlSchema(Resource resource) { private void addXsdSchema(Resource resource) { if (logger.isDebugEnabled()) { - logger.debug("Loading XSD schema resource " + resource.getLocation()); + logger.debug("Loading XSD schema resource '{}'", resource.getLocation()); } SimpleXsdSchema schema = new SimpleXsdSchema(new ByteArrayResource(FileUtils.copyToByteArray(resource))); @@ -186,33 +161,4 @@ public void setSchemaMappingStrategy(XsdSchemaMappingStrategy schemaMappingStrat public XsdSchemaMappingStrategy getSchemaMappingStrategy() { return schemaMappingStrategy; } - - @Override - public void setName(String name) { - this.name = name; - } - - /** - * Gets the name. - * @return the name to get. - */ - public String getName() { - return name; - } - - /** - * Gets the locations. - * @return the locations to get. - */ - public List getLocations() { - return locations; - } - - /** - * Sets the locations. - * @param locations the locations to set - */ - public void setLocations(List locations) { - this.locations = locations; - } } From 411b37371f50f8b322f8c49ee9d7b4e89913186a Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Tue, 11 Jun 2024 07:21:00 +0200 Subject: [PATCH 2/7] fix(#1175): OpenAPI connector enhancements for simulator random message generation --- connectors/citrus-openapi/pom.xml | 6 ++ .../OpenApiClientResponseActionBuilder.java | 1 + .../openapi/model/OasModelHelper.java | 17 ++-- .../openapi/model/v2/Oas20ModelHelper.java | 18 ++-- .../openapi/model/v3/Oas30ModelHelper.java | 83 +++++++++---------- 5 files changed, 65 insertions(+), 60 deletions(-) diff --git a/connectors/citrus-openapi/pom.xml b/connectors/citrus-openapi/pom.xml index da92c9c032..fdeb577917 100644 --- a/connectors/citrus-openapi/pom.xml +++ b/connectors/citrus-openapi/pom.xml @@ -76,6 +76,12 @@ ${project.version} test
+ + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.17.0 + compile + diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java index 29b4d7e036..bfe7045f9d 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java @@ -97,6 +97,7 @@ public static void fillMessageFromResponse(OpenApiSpecification openApiSpecifica } } ); + } private static void fillRequiredHeaders( diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java index f8d1e703cd..ab77225e68 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java @@ -58,7 +58,7 @@ private OasModelHelper() { * @param schema to check * @return true if given schema is an object. */ - public static boolean isObjectType(OasSchema schema) { + public static boolean isObjectType(@Nullable OasSchema schema) { return schema != null && "object".equals(schema.type); } @@ -67,8 +67,8 @@ public static boolean isObjectType(OasSchema schema) { * @param schema to check * @return true if given schema is an array. */ - public static boolean isArrayType(OasSchema schema) { - return "array".equals(schema.type); + public static boolean isArrayType(@Nullable OasSchema schema) { + return schema != null && "array".equals(schema.type); } /** @@ -76,14 +76,14 @@ public static boolean isArrayType(OasSchema schema) { * @param schema to check * @return true if given schema is an object array. */ - public static boolean isObjectArrayType(OasSchema schema) { - if (schema == null || !"array".equals(schema.type)) { + public static boolean isObjectArrayType(@Nullable OasSchema schema) { + if (schema == null || !"array".equals(schema.type)) { return false; } Object items = schema.items; - if (items instanceof OasSchema oasSchema) { + if (items instanceof OasSchema oasSchema) { return isObjectType(oasSchema); - } else if (items instanceof List list) { + } else if (items instanceof List list) { return list.stream().allMatch(item -> item instanceof OasSchema oasSchema && isObjectType(oasSchema)); } @@ -95,7 +95,7 @@ public static boolean isObjectArrayType(OasSchema schema) { * @param schema to check * @return true if given schema has a reference. */ - public static boolean isReferenceType(OasSchema schema) { + public static boolean isReferenceType(@Nullable OasSchema schema) { return schema != null && schema.$ref != null; } @@ -194,7 +194,6 @@ public static String getReferenceName(String reference) { public static Optional getSchema(OasResponse response) { return delegate(response, Oas20ModelHelper::getSchema, Oas30ModelHelper::getSchema); } - public static Optional getParameterSchema(OasParameter parameter) { return delegate(parameter, Oas20ModelHelper::getParameterSchema, Oas30ModelHelper::getParameterSchema); } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java index d3b83c4000..f4480dffff 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java @@ -56,12 +56,12 @@ public static List getSchemes(Oas20Document openApiDoc) { public static String getBasePath(Oas20Document openApiDoc) { return Optional.ofNullable(openApiDoc.basePath) - .map(basePath -> basePath.startsWith("/") ? basePath : "/" + basePath).orElse("/"); + .map(basePath -> basePath.startsWith("/") ? basePath : "/" + basePath).orElse("/"); } public static Map getSchemaDefinitions(Oas20Document openApiDoc) { if (openApiDoc == null - || openApiDoc.definitions == null) { + || openApiDoc.definitions == null) { return Collections.emptyMap(); } @@ -72,7 +72,7 @@ public static Optional getSchema(Oas20Response response) { return Optional.ofNullable(response.schema); } - public static Optional getRequestBodySchema(Oas20Document openApiDoc, Oas20Operation operation) { + public static Optional getRequestBodySchema(@Nullable Oas20Document ignoredOpenApiDoc, Oas20Operation operation) { if (operation.parameters == null) { return Optional.empty(); } @@ -80,8 +80,8 @@ public static Optional getRequestBodySchema(Oas20Document openApiDoc, final List operationParameters = operation.parameters; Optional body = operationParameters.stream() - .filter(p -> "body".equals(p.in) && p.schema != null) - .findFirst(); + .filter(p -> "body".equals(p.in) && p.schema != null) + .findFirst(); return body.map(oasParameter -> (OasSchema) oasParameter.schema); } @@ -94,7 +94,7 @@ public static Optional getRequestContentType(Oas20Operation operation) { return Optional.empty(); } - public static Collection getResponseTypes(Oas20Operation operation, @Nullable Oas20Response response) { + public static Collection getResponseTypes(Oas20Operation operation,@Nullable Oas20Response ignoredResponse) { if (operation == null) { return Collections.emptyList(); } @@ -105,11 +105,11 @@ public static Collection getResponseTypes(Oas20Operation operation, @Nul * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. * - * @param openApiDoc + * @param ignoredOpenApiDoc required to implement quasi interface but ignored in this implementation. * @param operation * @return */ - public static Optional getResponseContentTypeForRandomGeneration(@Nullable Oas20Document openApiDoc, Oas20Operation operation) { + public static Optional getResponseContentTypeForRandomGeneration(@Nullable Oas20Document ignoredOpenApiDoc, Oas20Operation operation) { if (operation.produces != null) { for (String mediaType : operation.produces) { if (MediaType.APPLICATION_JSON_VALUE.equals(mediaType)) { @@ -154,7 +154,7 @@ public static Map getHeaders(Oas20Response response) { } return response.headers.getHeaders().stream() - .collect(Collectors.toMap(OasHeader::getName, Oas20ModelHelper::getHeaderSchema)); + .collect(Collectors.toMap(OasHeader::getName, Oas20ModelHelper::getHeaderSchema)); } /** diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java index 0cdf5c9227..3b2c1995bc 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java @@ -26,7 +26,6 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; -import jakarta.annotation.Nullable; import java.net.MalformedURLException; import java.net.URL; import java.util.Arrays; @@ -79,17 +78,17 @@ public static List getSchemes(Oas30Document openApiDoc) { } return openApiDoc.servers.stream() - .map(Oas30ModelHelper::resolveUrl) - .map(serverUrl -> { - try { - return new URL(serverUrl).getProtocol(); - } catch (MalformedURLException e) { - LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); - return null; - } - }) - .filter(Objects::nonNull) - .toList(); + .map(Oas30ModelHelper::resolveUrl) + .map(serverUrl -> { + try { + return new URL(serverUrl).getProtocol(); + } catch (MalformedURLException e) { + LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); + return null; + } + }) + .filter(Objects::nonNull) + .toList(); } public static String getBasePath(Oas30Document openApiDoc) { @@ -120,8 +119,8 @@ public static Map getSchemaDefinitions(Oas30Document openApiD } return openApiDoc.components.schemas.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue)); + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue)); } public static Optional getSchema(Oas30Response response) { @@ -131,11 +130,11 @@ public static Optional getSchema(Oas30Response response) { } return content.entrySet() - .stream() - .filter(entry -> !isFormDataMediaType(entry.getKey())) - .filter(entry -> entry.getValue().schema != null) - .map(entry -> (OasSchema) entry.getValue().schema) - .findFirst(); + .stream() + .filter(entry -> !isFormDataMediaType(entry.getKey())) + .filter(entry -> entry.getValue().schema != null) + .map(entry -> (OasSchema) entry.getValue().schema) + .findFirst(); } public static Optional getRequestBodySchema(Oas30Document openApiDoc, Oas30Operation operation) { @@ -146,8 +145,8 @@ public static Optional getRequestBodySchema(Oas30Document openApiDoc, Oas30RequestBody bodyToUse = operation.requestBody; if (openApiDoc.components != null - && openApiDoc.components.requestBodies != null - && bodyToUse.$ref != null) { + && openApiDoc.components.requestBodies != null + && bodyToUse.$ref != null) { bodyToUse = openApiDoc.components.requestBodies.get(OasModelHelper.getReferenceName(bodyToUse.$ref)); } @@ -156,12 +155,12 @@ public static Optional getRequestBodySchema(Oas30Document openApiDoc, } return bodyToUse.content.entrySet() - .stream() - .filter(entry -> !isFormDataMediaType(entry.getKey())) - .filter(entry -> entry.getValue().schema != null) - .findFirst() - .map(Map.Entry::getValue) - .map(oas30MediaType -> oas30MediaType.schema); + .stream() + .filter(entry -> !isFormDataMediaType(entry.getKey())) + .filter(entry -> entry.getValue().schema != null) + .findFirst() + .map(Map.Entry::getValue) + .map(oas30MediaType -> oas30MediaType.schema); } public static Optional getRequestContentType(Oas30Operation operation) { @@ -170,13 +169,13 @@ public static Optional getRequestContentType(Oas30Operation operation) { } return operation.requestBody.content.entrySet() - .stream() - .filter(entry -> entry.getValue().schema != null) - .map(Map.Entry::getKey) - .findFirst(); + .stream() + .filter(entry -> entry.getValue().schema != null) + .map(Map.Entry::getKey) + .findFirst(); } - public static Collection getResponseTypes(@Nullable Oas30Operation operation, Oas30Response response) { + public static Collection getResponseTypes(Oas30Operation operation, Oas30Response response) { if (operation == null) { return Collections.emptySet(); } @@ -195,12 +194,12 @@ public static Optional getResponseContentTypeForRandomGeneration(Oas30Do Optional responseForRandomGeneration = getResponseForRandomGeneration( openApiDoc, operation); return responseForRandomGeneration.map( - Oas30Response.class::cast).flatMap(res -> res.content.entrySet() - .stream() + Oas30Response.class::cast).flatMap(res -> res.content.entrySet() + .stream() .filter(entry -> MediaType.APPLICATION_JSON_VALUE.equals(entry.getKey())) - .filter(entry -> entry.getValue().schema != null) - .map(Map.Entry::getKey) - .findFirst()); + .filter(entry -> entry.getValue().schema != null) + .map(Map.Entry::getKey) + .findFirst()); } public static Optional getResponseForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { @@ -247,9 +246,9 @@ public static Map getRequiredHeaders(Oas30Response response) } return response.headers.entrySet() - .stream() - .filter(entry -> Boolean.TRUE.equals(entry.getValue().required)) - .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); + .stream() + .filter(entry -> Boolean.TRUE.equals(entry.getValue().required)) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); } public static Map getHeaders(Oas30Response response) { @@ -258,8 +257,8 @@ public static Map getHeaders(Oas30Response response) { } return response.headers.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); } private static boolean isFormDataMediaType(String type) { From 8d5bb14fcce9bcc318668202b0e50140a1391ce7 Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Fri, 21 Jun 2024 07:27:10 +0200 Subject: [PATCH 3/7] feat(#1175): Add OpenApiRepository and Validation --- connectors/citrus-openapi/pom.xml | 11 +- .../openapi/OpenApiPathRegistry.java | 191 +++++++++ .../openapi/OpenApiRepository.java | 90 +++- .../openapi/OpenApiResourceLoader.java | 138 ++++++- .../openapi/OpenApiSettings.java | 49 +++ .../openapi/OpenApiSpecification.java | 346 ++++++++++++++-- .../openapi/OpenApiSpecificationAdapter.java | 43 ++ .../openapi/OpenApiTestDataGenerator.java | 40 +- .../citrusframework/openapi/OpenApiUtils.java | 55 +++ .../openapi/actions/OpenApiActionBuilder.java | 7 +- .../OpenApiClientRequestActionBuilder.java | 134 +++--- .../OpenApiClientResponseActionBuilder.java | 63 ++- .../actions/OpenApiServerActionBuilder.java | 22 +- .../OpenApiServerRequestActionBuilder.java | 167 ++++---- .../OpenApiServerResponseActionBuilder.java | 229 ++++++++--- .../openapi/model/OasAdapter.java | 24 ++ .../openapi/model/OasModelHelper.java | 207 ++++++++-- .../openapi/model/OasOperationVisitor.java | 28 ++ .../openapi/model/OperationPathAdapter.java | 39 ++ .../openapi/model/v2/Oas20ModelHelper.java | 84 ++-- .../openapi/model/v3/Oas30ModelHelper.java | 159 +++---- .../OpenApiRequestValidationProcessor.java | 60 +++ .../validation/OpenApiRequestValidator.java | 97 +++++ .../OpenApiResponseValidationProcessor.java | 58 +++ .../validation/OpenApiResponseValidator.java | 77 ++++ .../openapi/validation/OpenApiValidator.java | 61 +++ .../openapi/OpenApiPathRegistryTest.java | 171 ++++++++ .../openapi/OpenApiRepositoryTest.java | 86 +++- .../openapi/OpenApiSettingsTest.java | 196 +++++++++ .../OpenApiSpecificationAdapterTest.java | 49 +++ .../openapi/OpenApiSpecificationTest.java | 387 ++++++++++++++++++ .../openapi/OpenApiTestDataGeneratorTest.java | 49 +++ .../openapi/OpenApiUtilsTest.java | 80 ++++ .../openapi/groovy/OpenApiServerTest.java | 118 +++--- .../openapi/integration/OpenApiClientIT.java | 111 ++++- .../openapi/integration/OpenApiServerIT.java | 132 +++++- .../model/OperationPathAdapterTest.java | 34 ++ .../model/v2/Oas20ModelHelperTest.java | 41 +- .../model/v3/Oas30ModelHelperTest.java | 48 ++- ...OpenApiRequestValidationProcessorTest.java | 118 ++++++ .../OpenApiRequestValidatorTest.java | 148 +++++++ ...penApiResponseValidationProcessorTest.java | 114 ++++++ .../OpenApiResponseValidatorTest.java | 128 ++++++ .../openapi/xml/OpenApiClientTest.java | 4 +- .../openapi/xml/OpenApiServerTest.java | 3 +- .../openapi/yaml/OpenApiServerTest.java | 3 +- .../openapi/petstore/pet_invalid.json | 15 + .../openapi/ping/ping-api.yaml | 244 +++++++++++ .../message/DefaultMessage.java | 1 + .../org/citrusframework/util/StringUtils.java | 12 +- .../citrusframework/util/StringUtilsTest.java | 18 +- .../http/message/HttpMessage.java | 6 +- pom.xml | 19 + .../spring/TestNGCitrusSpringSupport.java | 8 +- 54 files changed, 4200 insertions(+), 622 deletions(-) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java create mode 100644 connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json create mode 100644 connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml diff --git a/connectors/citrus-openapi/pom.xml b/connectors/citrus-openapi/pom.xml index fdeb577917..19c51f6355 100644 --- a/connectors/citrus-openapi/pom.xml +++ b/connectors/citrus-openapi/pom.xml @@ -45,6 +45,11 @@ io.apicurio apicurio-data-models + + com.atlassian.oai + swagger-request-validator-core + 2.40.0 + com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -76,12 +81,6 @@ ${project.version} test - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - 2.17.0 - compile - diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java new file mode 100644 index 0000000000..1c05877806 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java @@ -0,0 +1,191 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static java.lang.String.format; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A registry to store objects by OpenApi paths. The registry uses a digital tree data structure + * that performs path matching with variable placeholders. Variable + * placeholders must be enclosed in curly braces '{}', e.g., '/api/v1/pet/{id}'. This data structure + * is optimized for matching paths efficiently, handling both static and dynamic segments. + *

+ * This class is currently not in use but may serve scenarios where a path needs to be mapped to an + * OasOperation without explicit knowledge of the API to which the path belongs. + * It could be utilized, for instance, in implementing an OAS message validator based on + * {@link org.citrusframework.validation.AbstractMessageValidator}. + */ +public class OpenApiPathRegistry { + + private static final Logger logger = LoggerFactory.getLogger(OpenApiPathRegistry.class); + + private final RegistryNode root = new RegistryNode(); + + private final Map allPaths = new ConcurrentHashMap<>(); + + public T search(String path) { + RegistryNode trieNode = internalSearch(path); + return trieNode != null ? trieNode.value : null; + } + + RegistryNode internalSearch(String path) { + String[] segments = path.split("/"); + return searchHelper(root, segments, 0); + } + + public boolean insert(String path, T value) { + return insertInternal(path, value) != null; + } + + RegistryNode insertInternal(String path, T value) { + + if (path == null || value == null) { + return null; + } + + String[] segments = path.split("/"); + RegistryNode node = root; + + if (!allPaths.isEmpty() && (isPathAlreadyContainedWithDifferentValue(path, value) + || isPathMatchedByOtherPath(path, value))) { + return null; + } + + allPaths.put(path, value); + StringBuilder builder = new StringBuilder(); + for (String segment : segments) { + if (builder.isEmpty() || builder.charAt(builder.length() - 1) != '/') { + builder.append("/"); + } + builder.append(segment); + + if (!node.children.containsKey(segment)) { + RegistryNode trieNode = new RegistryNode(); + trieNode.path = builder.toString(); + node.children.put(segment, trieNode); + } + node = node.children.get(segment); + } + + // Sanity check to disallow overwrite of existing values + if (node.value != null && !node.value.equals(value)) { + throw new CitrusRuntimeException(format( + "Illegal attempt to overwrite an existing node value. This is probably a bug. path=%s value=%s", + node.path, node.value)); + } + node.value = value; + + return node; + } + + /** + * Tests if the path is either matching an existing path or any existing path matches the given + * patch. + *

+ * For example '/a/b' does not match '/{a}/{b}', but '/{a}/{b}' matches '/a/b'. + */ + private boolean isPathMatchedByOtherPath(String path, T value) { + + // Does the given path match any existing + RegistryNode currentValue = internalSearch(path); + if (currentValue != null && !Objects.equals(path, currentValue.path)) { + logger.error( + "Attempt to insert an equivalent path potentially overwriting an existing value. Value for path is ignored: path={}, value={} currentValue={} ", + path, currentValue, value); + return true; + } + + // Does any existing match the path. + OpenApiPathRegistry tmpTrie = new OpenApiPathRegistry<>(); + tmpTrie.insert(path, value); + + List allMatching = allPaths.keySet().stream() + .filter(existingPath -> { + RegistryNode trieNode = tmpTrie.internalSearch(existingPath); + return trieNode != null && !existingPath.equals(trieNode.path); + }).map(existingPath -> "'" + existingPath + "'").toList(); + if (!allMatching.isEmpty() && logger.isErrorEnabled()) { + logger.error( + "Attempt to insert an equivalent path overwritten by existing paths. Value for path is ignored: path={}, value={} existingPaths=[{}]", + path, currentValue, String.join(",", allMatching)); + + } + + return !allMatching.isEmpty(); + } + + private boolean isPathAlreadyContainedWithDifferentValue(String path, T value) { + T currentValue = allPaths.get(path); + if (currentValue != null) { + if (value.equals(currentValue)) { + return false; + } + logger.error( + "Attempt to overwrite value for path is ignored: path={}, value={} currentValue={} ", + path, currentValue, value); + return true; + } + return false; + } + + private RegistryNode searchHelper(RegistryNode node, String[] segments, int index) { + if (node == null) { + return null; + } + if (index == segments.length) { + return node; + } + + String segment = segments[index]; + + // Exact match + if (node.children.containsKey(segment)) { + RegistryNode foundNode = searchHelper(node.children.get(segment), segments, index + 1); + if (foundNode != null && foundNode.value != null) { + return foundNode; + } + } + + // Variable match + for (String key : node.children.keySet()) { + if (key.startsWith("{") && key.endsWith("}")) { + RegistryNode foundNode = searchHelper(node.children.get(key), segments, index + 1); + if (foundNode != null && foundNode.value != null) { + return foundNode; + } + } + } + + return null; + } + + class RegistryNode { + Map children = new HashMap<>(); + String path; + T value = null; + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java index 75b62e59c7..36f113ea83 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java @@ -16,26 +16,45 @@ package org.citrusframework.openapi; +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.citrusframework.repository.BaseRepository; import org.citrusframework.spi.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * OpenApi repository holding a set of {@link OpenApiSpecification} known in the test scope. + * * @since 4.4.0 */ public class OpenApiRepository extends BaseRepository { + private static final Logger logger = LoggerFactory.getLogger(OpenApiRepository.class); + private static final String DEFAULT_NAME = "openApiSchemaRepository"; - /** List of schema resources */ + /** + * List of schema resources + */ private final List openApiSpecifications = new ArrayList<>(); - - /** An optional context path, used for each api, without taking into account any {@link OpenApiSpecification} specific context path. */ + /** + * An optional context path, used for each api, without taking into account any + * {@link OpenApiSpecification} specific context path. + */ private String rootContextPath; + private boolean requestValidationEnabled = true; + + private boolean responseValidationEnabled = true; + public OpenApiRepository() { super(DEFAULT_NAME); } @@ -48,19 +67,80 @@ public void setRootContextPath(String rootContextPath) { this.rootContextPath = rootContextPath; } + public boolean isRequestValidationEnabled() { + return requestValidationEnabled; + } + + public void setRequestValidationEnabled(boolean requestValidationEnabled) { + this.requestValidationEnabled = requestValidationEnabled; + } + + public boolean isResponseValidationEnabled() { + return responseValidationEnabled; + } + + public void setResponseValidationEnabled(boolean responseValidationEnabled) { + this.responseValidationEnabled = responseValidationEnabled; + } + + /** + * Adds an OpenAPI Specification specified by the given resource to the repository. + * If an alias is determined from the resource name, it is added to the specification. + * + * @param openApiResource the resource to add as an OpenAPI specification + */ @Override public void addRepository(Resource openApiResource) { - OpenApiSpecification openApiSpecification = OpenApiSpecification.from(openApiResource); + determineResourceAlias(openApiResource).ifPresent(openApiSpecification::addAlias); + openApiSpecification.setRequestValidationEnabled(requestValidationEnabled); + openApiSpecification.setResponseValidationEnabled(responseValidationEnabled); openApiSpecification.setRootContextPath(rootContextPath); this.openApiSpecifications.add(openApiSpecification); - OpenApiSpecificationProcessor.lookup().values().forEach(processor -> processor.process(openApiSpecification)); + OpenApiSpecificationProcessor.lookup().values() + .forEach(processor -> processor.process(openApiSpecification)); + } + + /** + * @param openApiResource the OpenAPI resource from which to determine the alias + * @return an {@code Optional} containing the resource alias if it can be resolved, otherwise an empty {@code Optional} + */ + // Package protection for testing + static Optional determineResourceAlias(Resource openApiResource) { + String resourceAlias = null; + + try { + File file = openApiResource.getFile(); + if (file != null) { + return Optional.of(file.getName()); + } + } catch (Exception e) { + // Ignore and try with url + } + + try { + URL url = openApiResource.getURL(); + if (url != null) { + String urlString = URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8).replace("\\","/"); + int index = urlString.lastIndexOf("/"); + resourceAlias = urlString; + if (index != -1 && index != urlString.length()-1) { + resourceAlias = resourceAlias.substring(index+1); + } + } + } catch (MalformedURLException e) { + logger.error("Unable to determine resource alias from resource!", e); + } + + return Optional.ofNullable(resourceAlias); } public List getOpenApiSpecifications() { return openApiSpecifications; } + + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java index ed9b41c556..e78c8160f1 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java @@ -16,6 +16,11 @@ package org.citrusframework.openapi; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.apicurio.datamodels.Library; +import io.apicurio.datamodels.openapi.models.OasDocument; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; @@ -25,14 +30,11 @@ import java.util.Objects; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; - -import com.fasterxml.jackson.databind.JsonNode; -import io.apicurio.datamodels.Library; -import io.apicurio.datamodels.openapi.models.OasDocument; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.ssl.SSLContexts; +import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.spi.Resource; import org.citrusframework.util.FileUtils; import org.springframework.http.HttpMethod; @@ -44,6 +46,11 @@ */ public final class OpenApiResourceLoader { + static final RawResolver RAW_RESOLVER = new RawResolver(); + + + static final OasResolver OAS_RESOLVER = new OasResolver(); + /** * Prevent instantiation of utility class. */ @@ -57,17 +64,40 @@ private OpenApiResourceLoader() { * @return */ public static OasDocument fromFile(String resource) { - return fromFile(FileUtils.getFileResource(resource)); + return fromFile(FileUtils.getFileResource(resource), OAS_RESOLVER); } /** - * Loads the specification from a file resource. Either classpath or file system resource path is supported. + * Loads the raw specification from a file resource. Either classpath or file system resource path is supported. + * @param resource + * @return + */ + public static String rawFromFile(String resource) { + return fromFile(FileUtils.getFileResource(resource), + RAW_RESOLVER); + } + + /** + * Loads the specification from a resource. * @param resource * @return */ public static OasDocument fromFile(Resource resource) { + return fromFile(resource, OAS_RESOLVER); + } + + /** + * Loads the raw specification from a resource. + * @param resource + * @return + */ + public static String rawFromFile(Resource resource) { + return fromFile(resource, RAW_RESOLVER); + } + + private static T fromFile(Resource resource, Resolver resolver) { try { - return resolve(FileUtils.readToString(resource)); + return resolve(FileUtils.readToString(resource), resolver); } catch (IOException e) { throw new IllegalStateException("Failed to parse Open API specification: " + resource, e); } @@ -79,6 +109,19 @@ public static OasDocument fromFile(Resource resource) { * @return */ public static OasDocument fromWebResource(URL url) { + return fromWebResource(url, OAS_RESOLVER); + } + + /** + * Loads raw specification from given web URL location. + * @param url + * @return + */ + public static String rawFromWebResource(URL url) { + return fromWebResource(url, RAW_RESOLVER); + } + + private static T fromWebResource(URL url, Resolver resolver) { HttpURLConnection con = null; try { con = (HttpURLConnection) url.openConnection(); @@ -88,9 +131,9 @@ public static OasDocument fromWebResource(URL url) { int status = con.getResponseCode(); if (status > 299) { throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), - new IOException(FileUtils.readToString(con.getErrorStream()))); + new IOException(FileUtils.readToString(con.getErrorStream()))); } else { - return resolve(FileUtils.readToString(con.getInputStream())); + return resolve(FileUtils.readToString(con.getInputStream()), resolver); } } catch (IOException e) { throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), e); @@ -107,14 +150,27 @@ public static OasDocument fromWebResource(URL url) { * @return */ public static OasDocument fromSecuredWebResource(URL url) { + return fromSecuredWebResource(url, OAS_RESOLVER); + } + + /** + * Loads raw specification from given web URL location using secured Http connection. + * @param url + * @return + */ + public static String rawFromSecuredWebResource(URL url) { + return fromSecuredWebResource(url, RAW_RESOLVER); + } + + private static T fromSecuredWebResource(URL url, Resolver resolver) { Objects.requireNonNull(url); HttpsURLConnection con = null; try { SSLContext sslcontext = SSLContexts - .custom() - .loadTrustMaterial(TrustAllStrategy.INSTANCE) - .build(); + .custom() + .loadTrustMaterial(TrustAllStrategy.INSTANCE) + .build(); HttpsURLConnection.setDefaultSSLSocketFactory(sslcontext.getSocketFactory()); HttpsURLConnection.setDefaultHostnameVerifier(NoopHostnameVerifier.INSTANCE); @@ -126,9 +182,9 @@ public static OasDocument fromSecuredWebResource(URL url) { int status = con.getResponseCode(); if (status > 299) { throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), - new IOException(FileUtils.readToString(con.getErrorStream()))); + new IOException(FileUtils.readToString(con.getErrorStream()))); } else { - return resolve(FileUtils.readToString(con.getInputStream())); + return resolve(FileUtils.readToString(con.getInputStream()), resolver); } } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) { throw new IllegalStateException("Failed to create https client for ssl connection", e); @@ -141,16 +197,64 @@ public static OasDocument fromSecuredWebResource(URL url) { } } - private static OasDocument resolve(String specification) { + private static T resolve(String specification, Resolver resolver) { if (isJsonSpec(specification)) { - return (OasDocument) Library.readDocumentFromJSONString(specification); + return resolver.resolveFromString(specification); } final JsonNode node = OpenApiSupport.json().convertValue(OpenApiSupport.yaml().load(specification), JsonNode.class); - return (OasDocument) Library.readDocument(node); + return resolver.resolveFromNode(node); } private static boolean isJsonSpec(final String specification) { return specification.trim().startsWith("{"); } + + private interface Resolver { + + T resolveFromString(String specification); + + T resolveFromNode(JsonNode node); + + } + + /** + * {@link Resolver} implementation, that resolves to {@link OasDocument}. + */ + private static class OasResolver implements Resolver { + + @Override + public OasDocument resolveFromString(String specification) { + return (OasDocument) Library.readDocumentFromJSONString(specification); + } + + @Override + public OasDocument resolveFromNode(JsonNode node) { + return (OasDocument) Library.readDocument(node); + } + } + + /** + * {@link Resolver} implementation, that resolves to {@link String}. + */ + private static class RawResolver implements Resolver { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String resolveFromString(String specification) { + return specification; + } + + @Override + public String resolveFromNode(JsonNode node) { + + try { + return mapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new CitrusRuntimeException("Unable to write OpenApi specification node to string!", e); + } + } + } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java new file mode 100644 index 0000000000..ea99928985 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java @@ -0,0 +1,49 @@ +package org.citrusframework.openapi; + +import static java.lang.Boolean.parseBoolean; + +/** + * The {@code OpenApiSettings} class provides configuration settings for enabling or disabling + * OpenAPI request and response validation globally. The settings can be controlled through + * system properties or environment variables. + */ +public class OpenApiSettings { + + public static final String GENERATE_OPTIONAL_FIELDS_PROPERTY = "citrus.openapi.generate.optional.fields"; + public static final String GENERATE_OPTIONAL_FIELDS_ENV = "CITRUS_OPENAPI_GENERATE_OPTIONAL_FIELDS"; + + public static final String VALIDATE_OPTIONAL_FIELDS_PROPERTY = "citrus.openapi.validate.optional.fields"; + public static final String VALIDATE_OPTIONAL_FIELDS_ENV = "CITRUS_OPENAPI_VALIDATE_OPTIONAL_FIELDS"; + + public static final String REQUEST_VALIDATION_ENABLED_PROPERTY = "citrus.openapi.validation.enabled.request"; + public static final String REQUEST_VALIDATION_ENABLED_ENV = "CITRUS_OPENAPI_VALIDATION_DISABLE_REQUEST"; + + public static final String RESPONSE_VALIDATION_ENABLED_PROPERTY = "citrus.openapi.validation.enabled.response"; + public static final String RESPONSE_VALIDATION_ENABLED_ENV = "CITRUS_OPENAPI_VALIDATION_DISABLE_RESPONSE"; + + private OpenApiSettings() { + // static access only + } + + public static boolean isGenerateOptionalFieldsGlobally() { + return parseBoolean(System.getProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, System.getenv(GENERATE_OPTIONAL_FIELDS_ENV) != null ? + System.getenv(GENERATE_OPTIONAL_FIELDS_ENV) : "true")); + } + + public static boolean isValidateOptionalFieldsGlobally() { + return parseBoolean(System.getProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, System.getenv(VALIDATE_OPTIONAL_FIELDS_ENV) != null ? + System.getenv(VALIDATE_OPTIONAL_FIELDS_ENV) : "true")); + } + + public static boolean isRequestValidationEnabledlobally() { + return parseBoolean(System.getProperty( + REQUEST_VALIDATION_ENABLED_PROPERTY, System.getenv(REQUEST_VALIDATION_ENABLED_ENV) != null ? + System.getenv(REQUEST_VALIDATION_ENABLED_ENV) : "true")); + } + + public static boolean isResponseValidationEnabledGlobally() { + return parseBoolean(System.getProperty( + RESPONSE_VALIDATION_ENABLED_PROPERTY, System.getenv(RESPONSE_VALIDATION_ENABLED_ENV) != null ? + System.getenv(RESPONSE_VALIDATION_ENABLED_ENV) : "true")); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java index 5c28f5e67a..918aee6f6c 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java @@ -16,44 +16,111 @@ package org.citrusframework.openapi; +import static org.citrusframework.openapi.OpenApiSettings.isGenerateOptionalFieldsGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isValidateOptionalFieldsGlobally; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.OpenApiInteractionValidator.Builder; +import io.apicurio.datamodels.core.models.common.Info; +import io.apicurio.datamodels.openapi.models.OasDocument; +import io.apicurio.datamodels.openapi.models.OasOperation; import java.net.MalformedURLException; import java.net.URL; +import java.util.Collection; import java.util.Collections; +import java.util.HashSet; +import java.util.Map; import java.util.Optional; - -import io.apicurio.datamodels.openapi.models.OasDocument; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.client.HttpClient; import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.validation.OpenApiRequestValidator; +import org.citrusframework.openapi.validation.OpenApiResponseValidator; import org.citrusframework.spi.Resource; import org.citrusframework.spi.Resources; +import org.citrusframework.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * OpenApi specification resolves URL or local file resources to a specification document. + *

+ * The OpenApiSpecification class is responsible for handling the loading and processing of OpenAPI + * specification documents from various sources, such as URLs or local files. It supports the + * extraction and usage of key information from these documents, facilitating the interaction with + * OpenAPI-compliant APIs. + *

+ *

+ * The class maintains a set of aliases derived from the OpenAPI document's information. These + * aliases typically include the title of the API and its version, providing easy reference and + * identification. For example, if the OpenAPI document's title is "Sample API" and its version is + * "1.0", the aliases set will include "Sample API" and "Sample API/1.0". + *

+ * Users are responsible for ensuring that the sources provided to this class have unique aliases, + * or at least use the correct alias. If the same API is registered with different versions, all + * versions will likely share the same title alias but can be distinguished by the version alias + * (e.g., "Sample API/1.0" and "Sample API/2.0"). This distinction is crucial to avoid conflicts and + * ensure the correct identification and reference of each OpenAPI specification. Also note, that + * aliases may be added manually or programmatically by + * {@link OpenApiSpecification#addAlias(String)}. */ public class OpenApiSpecification { + private static final Logger logger = LoggerFactory.getLogger(OpenApiSpecification.class); + public static final String HTTPS = "https"; public static final String HTTP = "http"; - /** URL to load the OpenAPI specification */ + + /** + * URL to load the OpenAPI specification + */ private String specUrl; private String httpClient; private String requestUrl; /** - * The optional root context path to which the OpenAPI is hooked. - * This path is prepended to the base path specified in the OpenAPI configuration. - * If no root context path is specified, only the base path and additional segments are used. + * The optional root context path to which the OpenAPI is hooked. This path is prepended to the + * base path specified in the OpenAPI configuration. If no root context path is specified, only + * the base path and additional segments are used. */ private String rootContextPath; private OasDocument openApiDoc; - private boolean generateOptionalFields = true; + private boolean generateOptionalFields = isGenerateOptionalFieldsGlobally(); + + private boolean validateOptionalFields = isValidateOptionalFieldsGlobally(); + + private boolean requestValidationEnabled = isRequestValidationEnabledlobally(); + + private boolean responseValidationEnabled = isResponseValidationEnabledGlobally(); + + private final Set aliases = Collections.synchronizedSet(new HashSet<>()); - private boolean validateOptionalFields = true; + /** + * Maps the identifier (id) of an operation to OperationPathAdapters. Two different keys may be used for each operation. + * Refer to {@link org.citrusframework.openapi.OpenApiSpecification#storeOperationPathAdapter} for more details. + */ + private final Map operationIdToOperationPathAdapter = new ConcurrentHashMap<>(); + + /** + * Stores the unique identifier (uniqueId) of an operation, derived from its HTTP method and path. + * This identifier can always be determined and is therefore safe to use, even for operations without + * an optional operationId defined. + */ + private final Map operationToUniqueId = new ConcurrentHashMap<>(); + + private OpenApiRequestValidator openApiRequestValidator; + + private OpenApiResponseValidator openApiResponseValidator; public static OpenApiSpecification from(String specUrl) { OpenApiSpecification specification = new OpenApiSpecification(); @@ -65,15 +132,25 @@ public static OpenApiSpecification from(String specUrl) { public static OpenApiSpecification from(URL specUrl) { OpenApiSpecification specification = new OpenApiSpecification(); OasDocument openApiDoc; + OpenApiInteractionValidator validator; if (specUrl.getProtocol().startsWith(HTTPS)) { openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specUrl); + validator = new OpenApiInteractionValidator.Builder().withInlineApiSpecification( + OpenApiResourceLoader.rawFromSecuredWebResource(specUrl)).build(); } else { openApiDoc = OpenApiResourceLoader.fromWebResource(specUrl); + validator = new OpenApiInteractionValidator.Builder().withInlineApiSpecification( + OpenApiResourceLoader.rawFromWebResource(specUrl)).build(); } specification.setSpecUrl(specUrl.toString()); + specification.initPathLookups(); specification.setOpenApiDoc(openApiDoc); - specification.setRequestUrl(String.format("%s://%s%s%s", specUrl.getProtocol(), specUrl.getHost(), specUrl.getPort() > 0 ? ":" + specUrl.getPort() : "", OasModelHelper.getBasePath(openApiDoc))); + specification.setValidator(validator); + specification.setRequestUrl( + String.format("%s://%s%s%s", specUrl.getProtocol(), specUrl.getHost(), + specUrl.getPort() > 0 ? ":" + specUrl.getPort() : "", + OasModelHelper.getBasePath(openApiDoc))); return specification; } @@ -81,23 +158,28 @@ public static OpenApiSpecification from(URL specUrl) { public static OpenApiSpecification from(Resource resource) { OpenApiSpecification specification = new OpenApiSpecification(); OasDocument openApiDoc = OpenApiResourceLoader.fromFile(resource); + OpenApiInteractionValidator validator = new Builder().withInlineApiSpecification( + OpenApiResourceLoader.rawFromFile(resource)).build(); specification.setOpenApiDoc(openApiDoc); + specification.setValidator(validator); String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) - .orElse(Collections.singletonList(HTTP)) - .stream() - .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) - .findFirst() - .orElse(HTTP); + .orElse(Collections.singletonList(HTTP)) + .stream() + .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) + .findFirst() + .orElse(HTTP); specification.setSpecUrl(resource.getLocation()); - specification.setRequestUrl(String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), OasModelHelper.getBasePath(openApiDoc))); + specification.setRequestUrl( + String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), + OasModelHelper.getBasePath(openApiDoc))); return specification; } - public OasDocument getOpenApiDoc(TestContext context) { + public synchronized OasDocument getOpenApiDoc(TestContext context) { if (openApiDoc != null) { return openApiDoc; } @@ -108,43 +190,63 @@ public OasDocument getOpenApiDoc(TestContext context) { if (resolvedSpecUrl.startsWith("/")) { // relative path URL - try to resolve with given request URL if (requestUrl != null) { - resolvedSpecUrl = requestUrl.endsWith("/") ? requestUrl + resolvedSpecUrl.substring(1) : requestUrl + resolvedSpecUrl; - } else if (httpClient != null && context.getReferenceResolver().isResolvable(httpClient, HttpClient.class)) { - String baseUrl = context.getReferenceResolver().resolve(httpClient, HttpClient.class).getEndpointConfiguration().getRequestUrl(); - resolvedSpecUrl = baseUrl.endsWith("/") ? baseUrl + resolvedSpecUrl.substring(1) : baseUrl + resolvedSpecUrl; + resolvedSpecUrl = + requestUrl.endsWith("/") ? requestUrl + resolvedSpecUrl.substring(1) + : requestUrl + resolvedSpecUrl; + } else if (httpClient != null && context.getReferenceResolver() + .isResolvable(httpClient, HttpClient.class)) { + String baseUrl = context.getReferenceResolver() + .resolve(httpClient, HttpClient.class).getEndpointConfiguration() + .getRequestUrl(); + resolvedSpecUrl = baseUrl.endsWith("/") ? baseUrl + resolvedSpecUrl.substring(1) + : baseUrl + resolvedSpecUrl; } else { - throw new CitrusRuntimeException(("Failed to resolve OpenAPI spec URL from relative path %s - " + - "make sure to provide a proper base URL when using relative paths").formatted(resolvedSpecUrl)); + throw new CitrusRuntimeException( + ("Failed to resolve OpenAPI spec URL from relative path %s - " + + "make sure to provide a proper base URL when using relative paths").formatted( + resolvedSpecUrl)); } } if (resolvedSpecUrl.startsWith(HTTP)) { - try { - URL specWebResource = new URL(resolvedSpecUrl); - if (resolvedSpecUrl.startsWith(HTTPS)) { - openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specWebResource); - } else { - openApiDoc = OpenApiResourceLoader.fromWebResource(specWebResource); - } - - if (requestUrl == null) { - setRequestUrl(String.format("%s://%s%s%s", specWebResource.getProtocol(), specWebResource.getHost(), specWebResource.getPort() > 0 ? ":" + specWebResource.getPort() : "", OasModelHelper.getBasePath(openApiDoc))); - } - } catch (MalformedURLException e) { - throw new IllegalStateException("Failed to retrieve Open API specification as web resource: " + specUrl, e); + + URL specWebResource = toSpecUrl(resolvedSpecUrl); + if (resolvedSpecUrl.startsWith(HTTPS)) { + initApiDoc( + () -> OpenApiResourceLoader.fromSecuredWebResource(specWebResource)); + setValidator(new OpenApiInteractionValidator.Builder().withInlineApiSpecification( + OpenApiResourceLoader.rawFromSecuredWebResource(specWebResource)).build()); + } else { + initApiDoc(() -> OpenApiResourceLoader.fromWebResource(specWebResource)); + setValidator(new OpenApiInteractionValidator.Builder().withInlineApiSpecification( + OpenApiResourceLoader.rawFromWebResource(specWebResource)).build()); + } + + if (requestUrl == null) { + setRequestUrl(String.format("%s://%s%s%s", specWebResource.getProtocol(), + specWebResource.getHost(), + specWebResource.getPort() > 0 ? ":" + specWebResource.getPort() : "", + OasModelHelper.getBasePath(openApiDoc))); } + } else { - openApiDoc = OpenApiResourceLoader.fromFile(Resources.create(resolvedSpecUrl)); + Resource resource = Resources.create(resolvedSpecUrl); + initApiDoc( + () -> OpenApiResourceLoader.fromFile(resource)); + setValidator(new OpenApiInteractionValidator.Builder().withInlineApiSpecification( + OpenApiResourceLoader.rawFromFile(resource)).build()); if (requestUrl == null) { String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) - .orElse(Collections.singletonList(HTTP)) - .stream() - .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) - .findFirst() - .orElse(HTTP); - - setRequestUrl(String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), OasModelHelper.getBasePath(openApiDoc))); + .orElse(Collections.singletonList(HTTP)) + .stream() + .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) + .findFirst() + .orElse(HTTP); + + setRequestUrl( + String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), + OasModelHelper.getBasePath(openApiDoc))); } } } @@ -152,8 +254,79 @@ public OasDocument getOpenApiDoc(TestContext context) { return openApiDoc; } - public void setOpenApiDoc(OasDocument openApiDoc) { - this.openApiDoc = openApiDoc; + // provided for testing + URL toSpecUrl(String resolvedSpecUrl) { + try { + return new URL(resolvedSpecUrl); + } catch (MalformedURLException e) { + throw new IllegalStateException( + "Failed to retrieve Open API specification as web resource: " + specUrl, e); + } + } + + void setOpenApiDoc(OasDocument openApiDoc) { + initApiDoc(() -> openApiDoc); + } + + private void setValidator(OpenApiInteractionValidator openApiInteractionValidator) { + openApiRequestValidator = new OpenApiRequestValidator(openApiInteractionValidator); + openApiRequestValidator.setEnabled(requestValidationEnabled); + + openApiResponseValidator = new OpenApiResponseValidator(openApiInteractionValidator); + openApiRequestValidator.setEnabled(responseValidationEnabled); + } + + private void initApiDoc(Supplier openApiDocSupplier) { + this.openApiDoc = openApiDocSupplier.get(); + this.aliases.addAll(collectAliases(openApiDoc)); + initPathLookups(); + } + + private void initPathLookups() { + + if (this.openApiDoc == null) { + return; + } + + operationIdToOperationPathAdapter.clear(); + OasModelHelper.visitOasOperations(this.openApiDoc, (oasPathItem, oasOperation) -> { + String path = oasPathItem.getPath(); + + if (StringUtils.isEmpty(path)) { + logger.warn("Skipping path item without path."); + return; + } + + for (Map.Entry operationEntry : OasModelHelper.getOperationMap( + oasPathItem).entrySet()) { + storeOperationPathAdapter(operationEntry.getValue(), path); + } + }); + } + + /** + * Stores an {@link OperationPathAdapter} in {@link org.citrusframework.openapi.OpenApiSpecification#operationIdToOperationPathAdapter}. + * The adapter is stored using two keys: the operationId (optional) and the full path of the operation, including the method. + * The full path is always determinable and thus can always be safely used. + * + * @param operation The {@link OperationPathAdapter} to store. + * @param path The full path of the operation, including the method. + */ + private void storeOperationPathAdapter(OasOperation operation, String path) { + + String basePath = OasModelHelper.getBasePath(openApiDoc); + String fullOperationPath = StringUtils.appendSegmentToUrlPath(basePath, path); + + OperationPathAdapter operationPathAdapter = new OperationPathAdapter(path, rootContextPath, + StringUtils.appendSegmentToUrlPath(rootContextPath, path), operation); + + String uniqueOperationId = OpenApiUtils.createFullPathOperationIdentifier(fullOperationPath, operation); + operationToUniqueId.put(operation, uniqueOperationId); + + operationIdToOperationPathAdapter.put(uniqueOperationId, operationPathAdapter); + if (StringUtils.hasText(operation.operationId)) { + operationIdToOperationPathAdapter.put(operation.operationId, operationPathAdapter); + } } public String getSpecUrl() { @@ -184,6 +357,28 @@ public void setRequestUrl(String requestUrl) { this.requestUrl = requestUrl; } + public boolean isRequestValidationEnabled() { + return requestValidationEnabled; + } + + public void setRequestValidationEnabled(boolean enabled) { + this.requestValidationEnabled = enabled; + if (this.openApiRequestValidator != null) { + this.openApiRequestValidator.setEnabled(enabled); + } + } + + public boolean isResponseValidationEnabled() { + return responseValidationEnabled; + } + + public void setResponseValidationEnabled(boolean enabled) { + this.responseValidationEnabled = enabled; + if (this.openApiResponseValidator != null) { + this.openApiResponseValidator.setEnabled(enabled); + } + } + public boolean isGenerateOptionalFields() { return generateOptionalFields; } @@ -206,7 +401,68 @@ public String getRootContextPath() { public void setRootContextPath(String rootContextPath) { this.rootContextPath = rootContextPath; + initPathLookups(); } + public void addAlias(String alias) { + aliases.add(alias); + } + + public Set getAliases() { + return Collections.unmodifiableSet(aliases); + } + + private Collection collectAliases(OasDocument document) { + if (document == null) { + return Collections.emptySet(); + } + + Info info = document.info; + if (info == null) { + return Collections.emptySet(); + } + + Set set = new HashSet<>(); + if (StringUtils.hasText(info.title)) { + set.add(info.title); + + if (StringUtils.hasText(info.version)) { + set.add(info.title + "/" + info.version); + } + } + return set; + } + + public Optional getOperation(String operationId, TestContext context) { + + if (operationId == null) { + return Optional.empty(); + } + + // This is ugly, but we need not make sure that the openApiDoc is initialized, which might + // happen, when instance is created with org.citrusframework.openapi.OpenApiSpecification.from(java.lang.String) + if (openApiDoc == null) { + getOpenApiDoc(context); + } + + return Optional.ofNullable(operationIdToOperationPathAdapter.get(operationId)); + } + + public Optional getRequestValidator() { + return Optional.ofNullable(openApiRequestValidator); + } + + public Optional getResponseValidator() { + return Optional.ofNullable(openApiResponseValidator); + } + + public OpenApiSpecification withRootContext(String rootContextPath) { + setRootContextPath(rootContextPath); + return this; + } + + public String getUniqueId(OasOperation oasOperation) { + return operationToUniqueId.get(oasOperation); + } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java new file mode 100644 index 0000000000..fa3eaca62c --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java @@ -0,0 +1,43 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +/** + * Adapter class that links an OAS entity to its associated OpenAPI specification context. + * This class provides methods to access both the OpenAPI specification and the specific OAS entity. + * + * @param the type to which the specification is adapted. + */ +public class OpenApiSpecificationAdapter { + + private final OpenApiSpecification openApiSpecification; + + private final T entity; + + public OpenApiSpecificationAdapter(OpenApiSpecification openApiSpecification, T entity) { + this.openApiSpecification = openApiSpecification; + this.entity = entity; + } + + public OpenApiSpecification getOpenApiSpecification() { + return openApiSpecification; + } + + public T getEntity() { + return entity; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java index 6dac0a6072..0a0795db8b 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java @@ -96,6 +96,15 @@ public static String createRandomValueExpression(String name, OasSchema schema, return createRandomValueExpression(schema, definitions, quotes, specification); } + public static T createRawRandomValueExpression(String name, OasSchema schema, Map definitions, + boolean quotes, OpenApiSpecification specification, TestContext context) { + if (context.getVariables().containsKey(name)) { + return (T)context.getVariables().get(CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX); + } + + return createRawRandomValueExpression(schema, definitions, quotes, specification, context); + } + /** * Create payload from schema with random values. * @param schema @@ -121,7 +130,7 @@ public static String createRandomValueExpression(OasSchema schema, Map T createRawRandomValueExpression(OasSchema schema, Map definitions, boolean quotes, + OpenApiSpecification specification, TestContext context) { + if (OasModelHelper.isReferenceType(schema)) { + OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); + return createRawRandomValueExpression(resolved, definitions, quotes, specification, context); + } + + StringBuilder payload = new StringBuilder(); + if ("string".equals(schema.type) || OasModelHelper.isObjectType(schema) || OasModelHelper.isArrayType(schema)) { + return (T)createRandomValueExpression(schema, definitions, quotes, specification); + } else if ("number".equals(schema.type)) { + return (T)Double.valueOf(context.replaceDynamicContentInString("citrus:randomNumber(8,2)")); + } else if ("integer".equals(schema.type)) { + return (T)Double.valueOf(context.replaceDynamicContentInString("citrus:randomNumber(8)")); + } else if ("boolean".equals(schema.type)) { + return (T)Boolean.valueOf(context.replaceDynamicContentInString("citrus:randomEnumValue('true', 'false')")); + } else if (quotes) { + payload.append("\"\""); + } + + return (T)payload.toString(); + } + /** * Creates control payload from schema for validation. * @param schema @@ -285,7 +317,7 @@ private static String createValidationExpression(OasSchema schema) { if (schema.format != null && schema.format.equals("date")) { return "@matchesDatePattern('yyyy-MM-dd')@"; } else if (schema.format != null && schema.format.equals("date-time")) { - return "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@"; + return "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ssZ')@"; } else if (StringUtils.hasText(schema.pattern)) { return String.format("@matches(%s)@", schema.pattern); } else if (!CollectionUtils.isEmpty(schema.enum_)) { @@ -330,7 +362,7 @@ public static String createRandomValueExpression(OasSchema schema) { if (schema.format != null && schema.format.equals("date")) { return "\"citrus:currentDate('yyyy-MM-dd')\""; } else if (schema.format != null && schema.format.equals("date-time")) { - return "\"citrus:currentDate('yyyy-MM-dd'T'hh:mm:ss')\""; + return "\"citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')\""; } else if (StringUtils.hasText(schema.pattern)) { return "\"citrus:randomValue(" + schema.pattern + ")\""; } else if (!CollectionUtils.isEmpty(schema.enum_)) { @@ -376,7 +408,7 @@ public static String createValidationRegex(OasSchema schema) { if (schema.format != null && schema.format.equals("date")) { return "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])"; } else if (schema.format != null && schema.format.equals("date-time")) { - return "\\d{4}-\\d{2}-\\d{2}T[01]\\d:[0-5]\\d:[0-5]\\d"; + return "\\d{4}-\\d{2}-\\d{2}T[01]\\d:[0-5]\\d:[0-5]\\dZ"; } else if (StringUtils.hasText(schema.pattern)) { return schema.pattern; } else if (!CollectionUtils.isEmpty(schema.enum_)) { diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java new file mode 100644 index 0000000000..c7f1c7b1e4 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java @@ -0,0 +1,55 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static java.lang.String.format; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import jakarta.annotation.Nonnull; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.util.StringUtils; + +public class OpenApiUtils { + + private OpenApiUtils() { + // Static access only + } + + public static String getMethodPath(@Nonnull HttpMessage httpMessage) { + Object methodHeader = httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_METHOD); + Object path = httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI); + + return getMethodPath(methodHeader != null ? methodHeader.toString().toLowerCase() : "null", + path != null? path.toString() : "null"); + } + + public static String getMethodPath(@Nonnull String method, @Nonnull String path) { + if (StringUtils.hasText(path) && path.startsWith("/")) { + path = path.substring(1); + } + return String.format("/%s/%s", method.toLowerCase(), path); + } + + /** + * @return a unique scenario id for the {@link OasOperation} + */ + public static String createFullPathOperationIdentifier(String path, OasOperation oasOperation) { + return format("%s_%s", oasOperation.getMethod().toUpperCase(), path); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java index cd60dc39b1..81892f9076 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java @@ -17,7 +17,6 @@ package org.citrusframework.openapi.actions; import java.net.URL; - import org.citrusframework.TestAction; import org.citrusframework.TestActionBuilder; import org.citrusframework.endpoint.Endpoint; @@ -167,11 +166,11 @@ public TestActionBuilder getDelegate() { */ @Override public void setReferenceResolver(ReferenceResolver referenceResolver) { - if (referenceResolver == null) { + if (referenceResolver != null) { this.referenceResolver = referenceResolver; - if (delegate instanceof ReferenceResolverAware) { - ((ReferenceResolverAware) delegate).setReferenceResolver(referenceResolver); + if (delegate instanceof ReferenceResolverAware referenceResolverAware) { + referenceResolverAware.setReferenceResolver(referenceResolver); } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java index d646202c4d..e18f0d8f80 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java @@ -16,17 +16,13 @@ package org.citrusframework.openapi.actions; +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasParameter; +import io.apicurio.datamodels.openapi.models.OasSchema; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Optional; import java.util.regex.Pattern; - -import io.apicurio.datamodels.openapi.models.OasDocument; -import io.apicurio.datamodels.openapi.models.OasOperation; -import io.apicurio.datamodels.openapi.models.OasParameter; -import io.apicurio.datamodels.openapi.models.OasPathItem; -import io.apicurio.datamodels.openapi.models.OasSchema; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -37,6 +33,8 @@ import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.OpenApiTestDataGenerator; import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.validation.OpenApiRequestValidationProcessor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -46,6 +44,8 @@ */ public class OpenApiClientRequestActionBuilder extends HttpClientRequestActionBuilder { + private final OpenApiRequestValidationProcessor openApiRequestValidationProcessor; + /** * Default constructor initializes http request message builder. */ @@ -56,6 +56,16 @@ public OpenApiClientRequestActionBuilder(OpenApiSpecification openApiSpec, Strin public OpenApiClientRequestActionBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, String operationId) { super(new OpenApiClientRequestMessageBuilder(httpMessage, openApiSpec, operationId), httpMessage); + + openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId); + process(openApiRequestValidationProcessor); + } + + public OpenApiClientRequestActionBuilder disableOasValidation(boolean b) { + if (openApiRequestValidationProcessor != null) { + openApiRequestValidationProcessor.setEnabled(!b); + } + return this; } private static class OpenApiClientRequestMessageBuilder extends HttpMessageBuilder { @@ -75,65 +85,32 @@ public OpenApiClientRequestMessageBuilder(HttpMessage httpMessage, OpenApiSpecif @Override public Message build(TestContext context, String messageType) { - OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); - OasOperation operation = null; - OasPathItem pathItem = null; - HttpMethod method = null; - - for (OasPathItem path : OasModelHelper.getPathItems(oasDocument.paths)) { - Optional> operationEntry = OasModelHelper.getOperationMap(path).entrySet().stream() - .filter(op -> operationId.equals(op.getValue().operationId)) - .findFirst(); - - if (operationEntry.isPresent()) { - method = HttpMethod.valueOf(operationEntry.get().getKey().toUpperCase(Locale.US)); - operation = operationEntry.get().getValue(); - pathItem = path; - break; - } - } + openApiSpec.getOperation(operationId, context).ifPresentOrElse(operationPathAdapter -> + buildMessageFromOperation(operationPathAdapter, context), () -> { + throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); + }); - if (operation == null) { - throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); - } + return super.build(context, messageType); + } + + private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter, TestContext context) { + OasOperation operation = operationPathAdapter.operation(); + String path = operationPathAdapter.apiPath(); + HttpMethod method = HttpMethod.valueOf(operationPathAdapter.operation().getMethod().toUpperCase(Locale.US)); if (operation.parameters != null) { - List configuredHeaders = getHeaderBuilders() - .stream() - .flatMap(b -> b.builderHeaders(context).keySet().stream()) - .toList(); - operation.parameters.stream() - .filter(param -> "header".equals(param.in)) - .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) - .forEach(param -> { - if(httpMessage.getHeader(param.getName()) == null && !configuredHeaders.contains(param.getName())) { - httpMessage.setHeader(param.getName(), - OpenApiTestDataGenerator.createRandomValueExpression(param.getName(), (OasSchema) param.schema, - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context)); - } - }); - - operation.parameters.stream() - .filter(param -> "query".equals(param.in)) - .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) - .forEach(param -> { - if(!httpMessage.getQueryParams().containsKey(param.getName())) { - httpMessage.queryParam(param.getName(), - OpenApiTestDataGenerator.createRandomValueExpression(param.getName(), (OasSchema) param.schema, context)); - } - }); + setSpecifiedHeaders(context, operation); + setSpecifiedQueryParameters(context, operation); } if(httpMessage.getPayload() == null || (httpMessage.getPayload() instanceof String p && p.isEmpty())) { - Optional body = OasModelHelper.getRequestBodySchema(oasDocument, operation); - body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, - OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec))); + setSpecifiedBody(context, operation); } - String randomizedPath = pathItem.getPath(); + String randomizedPath = path; if (operation.parameters != null) { List pathParams = operation.parameters.stream() - .filter(p -> "path".equals(p.in)).toList(); + .filter(p -> "path".equals(p.in)).toList(); for (OasParameter parameter : pathParams) { String parameterValue; @@ -143,18 +120,55 @@ public Message build(TestContext context, String messageType) { parameterValue = OpenApiTestDataGenerator.createRandomValueExpression((OasSchema) parameter.schema); } randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") - .matcher(randomizedPath) - .replaceAll(parameterValue); + .matcher(randomizedPath) + .replaceAll(parameterValue); } } OasModelHelper.getRequestContentType(operation) - .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); + .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); httpMessage.path(randomizedPath); httpMessage.method(method); - return super.build(context, messageType); + } + + private void setSpecifiedBody(TestContext context, OasOperation operation) { + Optional body = OasModelHelper.getRequestBodySchema( + openApiSpec.getOpenApiDoc(context), operation); + body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, + OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), openApiSpec))); + } + + private void setSpecifiedQueryParameters(TestContext context, OasOperation operation) { + operation.parameters.stream() + .filter(param -> "query".equals(param.in)) + .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) + .forEach(param -> { + if(!httpMessage.getQueryParams().containsKey(param.getName())) { + httpMessage.queryParam(param.getName(), + OpenApiTestDataGenerator.createRandomValueExpression(param.getName(), (OasSchema) param.schema, + context)); + } + }); + } + + private void setSpecifiedHeaders(TestContext context, OasOperation operation) { + List configuredHeaders = getHeaderBuilders() + .stream() + .flatMap(b -> b.builderHeaders(context).keySet().stream()) + .toList(); + operation.parameters.stream() + .filter(param -> "header".equals(param.in)) + .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) + .forEach(param -> { + if(httpMessage.getHeader(param.getName()) == null && !configuredHeaders.contains(param.getName())) { + httpMessage.setHeader(param.getName(), + OpenApiTestDataGenerator.createRandomValueExpression(param.getName(), (OasSchema) param.schema, + OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc( + context)), false, openApiSpec, context)); + } + }); } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java index bfe7045f9d..a0b3a3433f 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java @@ -16,9 +16,7 @@ package org.citrusframework.openapi.actions; -import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; -import io.apicurio.datamodels.openapi.models.OasPathItem; import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import jakarta.annotation.Nullable; @@ -37,7 +35,8 @@ import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.OpenApiTestDataGenerator; import org.citrusframework.openapi.model.OasModelHelper; -import org.citrusframework.util.StringUtils; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.validation.OpenApiResponseValidationProcessor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -48,6 +47,7 @@ */ public class OpenApiClientResponseActionBuilder extends HttpClientResponseActionBuilder { + private final OpenApiResponseValidationProcessor openApiResponseValidationProcessor; /** * Default constructor initializes http response message builder. */ @@ -61,6 +61,16 @@ public OpenApiClientResponseActionBuilder(HttpMessage httpMessage, String operationId, String statusCode) { super(new OpenApiClientResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode), httpMessage); + + openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, operationId); + validate(openApiResponseValidationProcessor); + } + + public OpenApiClientResponseActionBuilder disableOasValidation(boolean b) { + if (openApiResponseValidationProcessor != null) { + openApiResponseValidationProcessor.setEnabled(!b); + } + return this; } public static void fillMessageFromResponse(OpenApiSpecification openApiSpecification, @@ -146,40 +156,25 @@ public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage, @Override public Message build(TestContext context, String messageType) { - OasOperation operation = null; - OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); - - for (OasPathItem path : OasModelHelper.getPathItems(oasDocument.paths)) { - Optional> operationEntry = OasModelHelper.getOperationMap( - path).entrySet().stream() - .filter(op -> operationId.equals(op.getValue().operationId)) - .findFirst(); - - if (operationEntry.isPresent()) { - operation = operationEntry.get().getValue(); - break; - } - } - if (operation == null) { - throw new CitrusRuntimeException( - "Unable to locate operation with id '%s' in OpenAPI specification %s".formatted( - operationId, openApiSpec.getSpecUrl())); - } + openApiSpec.getOperation(operationId, context).ifPresentOrElse(operationPathAdapter -> + buildMessageFromOperation(operationPathAdapter, context), () -> { + throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); + }); + + return super.build(context, messageType); + } + + private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter, TestContext context) { + OasOperation operation = operationPathAdapter.operation(); if (operation.responses != null) { - OasResponse response; - - if (StringUtils.hasText(statusCode)) { - response = Optional.ofNullable(operation.responses.getItem(statusCode)) - .orElse(operation.responses.default_); - } else { - response = OasModelHelper.getResponseForRandomGeneration( - openApiSpec.getOpenApiDoc(null), operation) - .orElse(operation.responses.default_); - } + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + openApiSpec.getOpenApiDoc(context), operation, statusCode, null); - fillMessageFromResponse(openApiSpec, context, httpMessage, operation, response); + responseForRandomGeneration.ifPresent( + oasResponse -> fillMessageFromResponse(openApiSpec, context, httpMessage, + operation, oasResponse)); } if (Pattern.compile("\\d+").matcher(statusCode).matches()) { @@ -187,8 +182,6 @@ public Message build(TestContext context, String messageType) { } else { httpMessage.status(HttpStatus.OK); } - - return super.build(context, messageType); } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerActionBuilder.java index eafa3421e7..86756f5a3b 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerActionBuilder.java @@ -93,11 +93,25 @@ public OpenApiServerResponseActionBuilder send(String operationId, HttpStatus st return send(operationId, String.valueOf(status.value())); } + /** + * Send Http response messages as server to client. + */ + public OpenApiServerResponseActionBuilder send(String operationId, HttpStatus status, String accept) { + return send(operationId, String.valueOf(status.value()), accept); + } + /** * Send Http response messages as server to client. */ public OpenApiServerResponseActionBuilder send(String operationId, String statusCode) { - OpenApiServerResponseActionBuilder builder = new OpenApiServerResponseActionBuilder(specification, operationId, statusCode); + return send(operationId, statusCode, null); + } + + /** + * Send Http response messages as server to client. + */ + public OpenApiServerResponseActionBuilder send(String operationId, String statusCode, String accept) { + OpenApiServerResponseActionBuilder builder = new OpenApiServerResponseActionBuilder(specification, operationId, statusCode, accept); if (httpServer != null) { builder.endpoint(httpServer); } else { @@ -137,11 +151,11 @@ public TestActionBuilder getDelegate() { */ @Override public void setReferenceResolver(ReferenceResolver referenceResolver) { - if (referenceResolver == null) { + if (referenceResolver != null) { this.referenceResolver = referenceResolver; - if (delegate instanceof ReferenceResolverAware) { - ((ReferenceResolverAware) delegate).setReferenceResolver(referenceResolver); + if (delegate instanceof ReferenceResolverAware referenceResolverAware) { + referenceResolverAware.setReferenceResolver(referenceResolver); } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java index bdd5a98c95..518ea02ad3 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java @@ -21,18 +21,14 @@ import static org.citrusframework.message.MessageType.PLAINTEXT; import static org.citrusframework.message.MessageType.XML; import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType; -import static org.citrusframework.util.StringUtils.appendSegmentToPath; +import static org.citrusframework.util.StringUtils.appendSegmentToUrlPath; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; -import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; -import io.apicurio.datamodels.openapi.models.OasPathItem; import io.apicurio.datamodels.openapi.models.OasSchema; import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.Optional; import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; @@ -45,6 +41,8 @@ import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.OpenApiTestDataGenerator; import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.validation.OpenApiRequestValidationProcessor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -54,6 +52,8 @@ */ public class OpenApiServerRequestActionBuilder extends HttpServerRequestActionBuilder { + private final OpenApiRequestValidationProcessor openApiRequestValidationProcessor; + /** * Default constructor initializes http request message builder. */ @@ -61,9 +61,21 @@ public OpenApiServerRequestActionBuilder(OpenApiSpecification openApiSpec, Strin this(new HttpMessage(), openApiSpec, operationId); } - public OpenApiServerRequestActionBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, - String operationId) { - super(new OpenApiServerRequestMessageBuilder(httpMessage, openApiSpec, operationId), httpMessage); + public OpenApiServerRequestActionBuilder(HttpMessage httpMessage, + OpenApiSpecification openApiSpec, + String operationId) { + super(new OpenApiServerRequestMessageBuilder(httpMessage, openApiSpec, operationId), + httpMessage); + + openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId); + validate(openApiRequestValidationProcessor); + } + + public OpenApiServerRequestActionBuilder disableOasValidation(boolean b) { + if (openApiRequestValidationProcessor != null) { + openApiRequestValidationProcessor.setEnabled(!b); + } + return this; } private static class OpenApiServerRequestMessageBuilder extends HttpMessageBuilder { @@ -73,8 +85,9 @@ private static class OpenApiServerRequestMessageBuilder extends HttpMessageBuild private final HttpMessage httpMessage; - public OpenApiServerRequestMessageBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, - String operationId) { + public OpenApiServerRequestMessageBuilder(HttpMessage httpMessage, + OpenApiSpecification openApiSpec, + String operationId) { super(httpMessage); this.openApiSpec = openApiSpec; this.operationId = operationId; @@ -83,116 +96,114 @@ public OpenApiServerRequestMessageBuilder(HttpMessage httpMessage, OpenApiSpecif @Override public Message build(TestContext context, String messageType) { - OasOperationParams oasOperationParams = getResult(context); - if (oasOperationParams.operation() == null) { + openApiSpec.getOperation(operationId, context).ifPresentOrElse(operationPathAdapter -> + buildMessageFromOperation(operationPathAdapter, context), () -> { throw new CitrusRuntimeException("Unable to locate operation with id '%s' in OpenAPI specification %s".formatted(operationId, openApiSpec.getSpecUrl())); - } - - setSpecifiedMessageType(oasOperationParams); - setSpecifiedHeaders(context, oasOperationParams); - setSpecifiedQueryParameters(context, oasOperationParams); - setSpecifiedPath(context, oasOperationParams); - setSpecifiedBody(oasOperationParams); - setSpecifiedRequestContentType(oasOperationParams); - setSpecifiedMethod(oasOperationParams); + }); return super.build(context, messageType); } - private OasOperationParams getResult(TestContext context) { - OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); - OasOperation operation = null; - OasPathItem pathItem = null; - HttpMethod method = null; - - for (OasPathItem path : OasModelHelper.getPathItems(oasDocument.paths)) { - Optional> operationEntry = OasModelHelper.getOperationMap(path).entrySet().stream() - .filter(op -> operationId.equals(op.getValue().operationId)) - .findFirst(); - - if (operationEntry.isPresent()) { - method = HttpMethod.valueOf(operationEntry.get().getKey().toUpperCase(Locale.US)); - operation = operationEntry.get().getValue(); - pathItem = path; - break; - } - } - return new OasOperationParams(oasDocument, operation, pathItem, method); + private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter, TestContext context) { + + setSpecifiedMessageType(operationPathAdapter); + setSpecifiedHeaders(context, operationPathAdapter); + setSpecifiedQueryParameters(context, operationPathAdapter); + setSpecifiedPath(context, operationPathAdapter); + setSpecifiedBody(context, operationPathAdapter); + setSpecifiedRequestContentType(operationPathAdapter); + setSpecifiedMethod(operationPathAdapter); + } - private void setSpecifiedRequestContentType(OasOperationParams oasOperationParams) { - OasModelHelper.getRequestContentType(oasOperationParams.operation) - .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, String.format("@startsWith(%s)@", contentType))); + private void setSpecifiedRequestContentType(OperationPathAdapter operationPathAdapter) { + OasModelHelper.getRequestContentType(operationPathAdapter.operation()) + .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, + String.format("@startsWith(%s)@", contentType))); } - private void setSpecifiedPath(TestContext context, OasOperationParams oasOperationParams) { - String randomizedPath = OasModelHelper.getBasePath(oasOperationParams.oasDocument) + oasOperationParams.pathItem.getPath(); + private void setSpecifiedPath(TestContext context, OperationPathAdapter operationPathAdapter) { + String randomizedPath = OasModelHelper.getBasePath(openApiSpec.getOpenApiDoc(context)) + + operationPathAdapter.apiPath(); randomizedPath = randomizedPath.replace("//", "/"); - randomizedPath = appendSegmentToPath(openApiSpec.getRootContextPath(), randomizedPath); + randomizedPath = appendSegmentToUrlPath(openApiSpec.getRootContextPath(), randomizedPath); - if (oasOperationParams.operation.parameters != null) { - randomizedPath = determinePath(context, oasOperationParams.operation, randomizedPath); + if (operationPathAdapter.operation().parameters != null) { + randomizedPath = determinePath(context, operationPathAdapter.operation(), + randomizedPath); } httpMessage.path(randomizedPath); } - private void setSpecifiedBody(OasOperationParams oasOperationParams) { - Optional body = OasModelHelper.getRequestBodySchema(oasOperationParams.oasDocument, oasOperationParams.operation); - body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions( - oasOperationParams.oasDocument), openApiSpec))); + private void setSpecifiedBody(TestContext context, OperationPathAdapter operationPathAdapter) { + Optional body = OasModelHelper.getRequestBodySchema( + openApiSpec.getOpenApiDoc(context), operationPathAdapter.operation()); + body.ifPresent(oasSchema -> httpMessage.setPayload( + OpenApiTestDataGenerator.createInboundPayload(oasSchema, + OasModelHelper.getSchemaDefinitions( + openApiSpec.getOpenApiDoc(context)), openApiSpec))); } private String determinePath(TestContext context, OasOperation operation, String randomizedPath) { List pathParams = operation.parameters.stream() - .filter(p -> "path".equals(p.in)).toList(); + .filter(p -> "path".equals(p.in)).toList(); for (OasParameter parameter : pathParams) { String parameterValue; if (context.getVariables().containsKey(parameter.getName())) { - parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + CitrusSettings.VARIABLE_SUFFIX; + parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + + CitrusSettings.VARIABLE_SUFFIX; randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") .matcher(randomizedPath) .replaceAll(parameterValue); } else { - parameterValue = OpenApiTestDataGenerator.createValidationRegex(parameter.getName(), OasModelHelper.getParameterSchema(parameter).orElse(null)); + parameterValue = OpenApiTestDataGenerator.createValidationRegex( + parameter.getName(), + OasModelHelper.getParameterSchema(parameter).orElse(null)); randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") .matcher(randomizedPath) .replaceAll(parameterValue); - randomizedPath = format("@matches('%s')@", randomizedPath); + randomizedPath = format("@matches('%s')@", randomizedPath); } } return randomizedPath; } - private void setSpecifiedQueryParameters(TestContext context, OasOperationParams oasOperationParams) { + private void setSpecifiedQueryParameters(TestContext context, + OperationPathAdapter operationPathAdapter) { - if (oasOperationParams.operation.parameters == null) { + if (operationPathAdapter.operation().parameters == null) { return; } - oasOperationParams.operation.parameters.stream() - .filter(param -> "query".equals(param.in)) - .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName())) - .forEach(param -> httpMessage.queryParam(param.getName(), - OpenApiTestDataGenerator.createValidationExpression(param.getName(), OasModelHelper.getParameterSchema(param).orElse(null), - OasModelHelper.getSchemaDefinitions(oasOperationParams.oasDocument), false, openApiSpec, - context))); + operationPathAdapter.operation().parameters.stream() + .filter(param -> "query".equals(param.in)) + .filter( + param -> (param.required != null && param.required) || context.getVariables() + .containsKey(param.getName())) + .forEach(param -> httpMessage.queryParam(param.getName(), + OpenApiTestDataGenerator.createValidationExpression(param.getName(), + OasModelHelper.getParameterSchema(param).orElse(null), + OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), false, + openApiSpec, + context))); } - private void setSpecifiedHeaders(TestContext context, OasOperationParams oasOperationParams) { + private void setSpecifiedHeaders(TestContext context, + OperationPathAdapter operationPathAdapter) { - if (oasOperationParams.operation.parameters == null) { + if (operationPathAdapter.operation().parameters == null) { return; } - oasOperationParams.operation.parameters.stream() + operationPathAdapter.operation().parameters.stream() .filter(param -> "header".equals(param.in)) .filter( param -> (param.required != null && param.required) || context.getVariables() @@ -200,28 +211,30 @@ private void setSpecifiedHeaders(TestContext context, OasOperationParams oasOper .forEach(param -> httpMessage.setHeader(param.getName(), OpenApiTestDataGenerator.createValidationExpression(param.getName(), OasModelHelper.getParameterSchema(param).orElse(null), - OasModelHelper.getSchemaDefinitions(oasOperationParams.oasDocument), false, openApiSpec, + OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), false, + openApiSpec, context))); } - private void setSpecifiedMessageType(OasOperationParams oasOperationParams) { + private void setSpecifiedMessageType(OperationPathAdapter operationPathAdapter) { Optional requestContentType = getRequestContentType( - oasOperationParams.operation); - if (requestContentType.isPresent() && APPLICATION_JSON_VALUE.equals(requestContentType.get())) { + operationPathAdapter.operation()); + if (requestContentType.isPresent() && APPLICATION_JSON_VALUE.equals( + requestContentType.get())) { httpMessage.setType(JSON); - } else if (requestContentType.isPresent() && APPLICATION_XML_VALUE.equals(requestContentType.get())) { + } else if (requestContentType.isPresent() && APPLICATION_XML_VALUE.equals( + requestContentType.get())) { httpMessage.setType(XML); } else { httpMessage.setType(PLAINTEXT); } } - private void setSpecifiedMethod(OasOperationParams oasOperationParams) { - httpMessage.method(oasOperationParams.method); + private void setSpecifiedMethod(OperationPathAdapter operationPathAdapter) { + httpMessage.method(HttpMethod.valueOf(operationPathAdapter.operation().getMethod().toUpperCase())); } } - private record OasOperationParams(OasDocument oasDocument, OasOperation operation, OasPathItem pathItem, HttpMethod method) { - } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java index 36273da2a4..6896413d5d 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java @@ -16,26 +16,42 @@ package org.citrusframework.openapi.actions; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; +import static java.lang.Integer.parseInt; +import static java.util.Collections.singletonMap; +import static org.citrusframework.openapi.OpenApiTestDataGenerator.createOutboundPayload; +import static org.citrusframework.openapi.OpenApiTestDataGenerator.createRandomValueExpression; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; -import io.apicurio.datamodels.openapi.models.OasPathItem; import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.actions.HttpServerResponseActionBuilder; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageBuilder; +import org.citrusframework.http.message.HttpMessageHeaders; import org.citrusframework.message.Message; +import org.citrusframework.message.MessageHeaderBuilder; +import org.citrusframework.message.builder.DefaultHeaderBuilder; import org.citrusframework.openapi.OpenApiSpecification; -import org.citrusframework.openapi.OpenApiTestDataGenerator; +import org.citrusframework.openapi.model.OasAdapter; import org.citrusframework.openapi.model.OasModelHelper; -import org.springframework.http.HttpHeaders; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.validation.OpenApiResponseValidationProcessor; import org.springframework.http.HttpStatus; /** @@ -44,99 +60,190 @@ */ public class OpenApiServerResponseActionBuilder extends HttpServerResponseActionBuilder { + private final OpenApiResponseValidationProcessor openApiResponseValidationProcessor; + /** * Default constructor initializes http response message builder. */ - public OpenApiServerResponseActionBuilder(OpenApiSpecification openApiSpec, String operationId, String statusCode) { - this(new HttpMessage(), openApiSpec, operationId, statusCode); + public OpenApiServerResponseActionBuilder(OpenApiSpecification openApiSpec, String operationId, + String statusCode, String accept) { + this(new HttpMessage(), openApiSpec, operationId, statusCode, accept); + } + + public OpenApiServerResponseActionBuilder(HttpMessage httpMessage, + OpenApiSpecification openApiSpec, + String operationId, String statusCode, String accept) { + super(new OpenApiServerResponseMessageBuilder(httpMessage, openApiSpec, operationId, + statusCode, accept), httpMessage); + + openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, + operationId); + process(openApiResponseValidationProcessor); + } + + public OpenApiServerResponseActionBuilder disableOasValidation(boolean b) { + if (openApiResponseValidationProcessor != null) { + openApiResponseValidationProcessor.setEnabled(!b); + } + return this; } - public OpenApiServerResponseActionBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, - String operationId, String statusCode) { - super(new OpenApiServerResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode), httpMessage); + public OpenApiServerResponseActionBuilder enableRandomGeneration(boolean enable) { + ((OpenApiServerResponseMessageBuilder)getMessageBuilderSupport().getMessageBuilder()).enableRandomGeneration(enable); + return this; } private static class OpenApiServerResponseMessageBuilder extends HttpMessageBuilder { + private static final Pattern STATUS_CODE_PATTERN = Pattern.compile("\\d+"); + private final OpenApiSpecification openApiSpec; private final String operationId; private final String statusCode; + private final String accept; + private boolean randomGenerationEnabled = true; - private final HttpMessage httpMessage; - - public OpenApiServerResponseMessageBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, - String operationId, String statusCode) { + public OpenApiServerResponseMessageBuilder(HttpMessage httpMessage, + OpenApiSpecification openApiSpec, + String operationId, String statusCode, String accept) { super(httpMessage); this.openApiSpec = openApiSpec; this.operationId = operationId; this.statusCode = statusCode; - this.httpMessage = httpMessage; + this.accept = accept; + } + + public OpenApiServerResponseMessageBuilder enableRandomGeneration(boolean enable) { + this.randomGenerationEnabled = enable; + return this; } @Override public Message build(TestContext context, String messageType) { - OasOperation operation = null; - OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); - - for (OasPathItem path : OasModelHelper.getPathItems(oasDocument.paths)) { - Optional> operationEntry = OasModelHelper.getOperationMap(path).entrySet().stream() - .filter(op -> operationId.equals(op.getValue().operationId)) - .findFirst(); - if (operationEntry.isPresent()) { - operation = operationEntry.get().getValue(); - break; - } + if (STATUS_CODE_PATTERN.matcher(statusCode).matches()) { + getMessage().status(HttpStatus.valueOf(parseInt(statusCode))); + } else { + getMessage().status(OK); } - if (operation == null) { - throw new CitrusRuntimeException(("Unable to locate operation with id '%s' " + - "in OpenAPI specification %s").formatted(operationId, openApiSpec.getSpecUrl())); + List initialHeaderBuilders = new ArrayList<>(getHeaderBuilders()); + getHeaderBuilders().clear(); + + if (randomGenerationEnabled) { + openApiSpec.getOperation(operationId, context) + .ifPresentOrElse(operationPathAdapter -> + fillRandomData(operationPathAdapter, context), () -> { + throw new CitrusRuntimeException( + "Unable to locate operation with id '%s' in OpenAPI specification %s".formatted( + operationId, openApiSpec.getSpecUrl())); + }); } - if (operation.responses != null) { - buildResponse(context, operation, oasDocument); - } + // Initial header builder need to be prepended, so that they can overwrite randomly generated headers. + getHeaderBuilders().addAll(initialHeaderBuilders); - OasModelHelper.getResponseContentTypeForRandomGeneration(oasDocument, operation) - .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType)); + return super.build(context, messageType); + } - if (Pattern.compile("\\d+").matcher(statusCode).matches()) { - httpMessage.status(HttpStatus.valueOf(Integer.parseInt(statusCode))); - } else { - httpMessage.status(HttpStatus.OK); - } + private void fillRandomData(OperationPathAdapter operationPathAdapter, TestContext context) { + OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); - return super.build(context, messageType); + if (operationPathAdapter.operation().responses != null) { + buildResponse(context, operationPathAdapter.operation(), oasDocument); + } } private void buildResponse(TestContext context, OasOperation operation, OasDocument oasDocument) { - OasResponse response = Optional.ofNullable(operation.responses.getItem(statusCode)) - .orElse(operation.responses.default_); - - if (response != null) { - Map requiredHeaders = OasModelHelper.getRequiredHeaders(response); - for (Map.Entry header : requiredHeaders.entrySet()) { - httpMessage.setHeader(header.getKey(), - OpenApiTestDataGenerator.createRandomValueExpression(header.getKey(), header.getValue(), - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, - context)); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + openApiSpec.getOpenApiDoc(context), operation, statusCode, null); + + if (responseForRandomGeneration.isPresent()) { + buildRandomHeaders(context, oasDocument, responseForRandomGeneration.get()); + buildRandomPayload(operation, oasDocument, responseForRandomGeneration.get()); + } + } + + private void buildRandomHeaders(TestContext context, OasDocument oasDocument, OasResponse response) { + Set filteredHeaders = new HashSet<>(getMessage().getHeaders().keySet()); + Predicate> filteredHeadersPredicate = entry -> !filteredHeaders.contains( + entry.getKey()); + + Map requiredHeaders = OasModelHelper.getRequiredHeaders( + response); + requiredHeaders.entrySet().stream() + .filter(filteredHeadersPredicate) + .forEach(entry -> addHeaderBuilder(new DefaultHeaderBuilder( + singletonMap(entry.getKey(), createRandomValueExpression(entry.getKey(), + entry.getValue(), + OasModelHelper.getSchemaDefinitions(oasDocument), false, + openApiSpec, + context)))) + ); + + // Also filter the required headers, as they have already been processed + filteredHeaders.addAll(requiredHeaders.keySet()); + + Map headers = OasModelHelper.getHeaders(response); + headers.entrySet().stream() + .filter(filteredHeadersPredicate) + .filter(entry -> context.getVariables().containsKey(entry.getKey())) + .forEach((entry -> addHeaderBuilder( + new DefaultHeaderBuilder(singletonMap(entry.getKey(), + CitrusSettings.VARIABLE_PREFIX + entry.getKey() + + CitrusSettings.VARIABLE_SUFFIX))))); + } + + private void buildRandomPayload(OasOperation operation, OasDocument oasDocument, + OasResponse response) { + + Optional> schemaForMediaTypeOptional; + if (statusCode.startsWith("2")) { + // if status code is good, and we have an accept, try to get the media type. Note that only json and plain text can be generated randomly. + schemaForMediaTypeOptional = OasModelHelper.getSchema(operation, + response, accept != null ? List.of(accept) : null); + } else { + // In the bad case, we cannot expect, that the accept type is the type which we must generate. + // We request the type supported by the response and the random generator (json and plain text). + schemaForMediaTypeOptional = OasModelHelper.getSchema(operation, response, null); + } + + if (schemaForMediaTypeOptional.isPresent()) { + OasAdapter schemaForMediaType = schemaForMediaTypeOptional.get(); + if (getMessage().getPayload() == null || ( + getMessage().getPayload() instanceof String string && string.isEmpty())) { + createRandomPayload(getMessage(), oasDocument, schemaForMediaType); } - Map headers = OasModelHelper.getHeaders(response); - for (Map.Entry header : headers.entrySet()) { - if (!requiredHeaders.containsKey(header.getKey()) && - context.getVariables().containsKey(header.getKey())) { - httpMessage.setHeader(header.getKey(), - CitrusSettings.VARIABLE_PREFIX + header.getKey() + CitrusSettings.VARIABLE_SUFFIX); - } + // If we have a schema and a media type and the content type has not yet been set, do it. + // If schema is null, we do not set the content type, as there is no content. + if (!getMessage().getHeaders().containsKey(HttpMessageHeaders.HTTP_CONTENT_TYPE) && schemaForMediaType.getAdapted() != null && schemaForMediaType.getNode() != null) { + addHeaderBuilder(new DefaultHeaderBuilder(singletonMap(HttpMessageHeaders.HTTP_CONTENT_TYPE, schemaForMediaType.getAdapted()))); } + } + } + + private void createRandomPayload(HttpMessage message, OasDocument oasDocument, OasAdapter schemaForMediaType) { - Optional responseSchema = OasModelHelper.getSchema(response); - responseSchema.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, - OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec))); + if (schemaForMediaType.getNode() == null) { + // No schema means no payload, no type + message.setPayload(null); + } else { + if (TEXT_PLAIN_VALUE.equals(schemaForMediaType.getAdapted())) { + // Schema but plain text + message.setPayload(createOutboundPayload(schemaForMediaType.getNode(), + OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)); + message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, TEXT_PLAIN_VALUE); + } else if (APPLICATION_JSON_VALUE.equals(schemaForMediaType.getAdapted())) { + // Json Schema + message.setPayload(createOutboundPayload(schemaForMediaType.getNode(), + OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)); + message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, APPLICATION_JSON_VALUE); + } } } } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java new file mode 100644 index 0000000000..a3f6fa8c52 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java @@ -0,0 +1,24 @@ +package org.citrusframework.openapi.model; + +import io.apicurio.datamodels.core.models.Node; + +public class OasAdapter { + + private final S node; + + private final T adapted; + + public OasAdapter(S node, T adapted) { + this.node = node; + this.adapted = adapted; + } + + public S getNode() { + return node; + } + + public T getAdapted() { + return adapted; + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java index ab77225e68..ddf0a74886 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java @@ -16,6 +16,9 @@ package org.citrusframework.openapi.model; +import static java.util.Collections.singletonList; + +import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; @@ -32,23 +35,37 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; -import java.util.ArrayList; +import jakarta.annotation.Nullable; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.Predicate; import org.citrusframework.openapi.model.v2.Oas20ModelHelper; import org.citrusframework.openapi.model.v3.Oas30ModelHelper; +import org.citrusframework.util.StringUtils; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch */ public final class OasModelHelper { + public static final String DEFAULT_ = "default_"; + + /** + * List of preferred media types in the order of priority, + * used when no specific 'Accept' header is provided to determine the default response type. + */ + public static final List DEFAULT_ACCEPTED_MEDIA_TYPES = List.of(MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_PLAIN_VALUE); + private OasModelHelper() { // utility class } @@ -77,9 +94,11 @@ public static boolean isArrayType(@Nullable OasSchema schema) { * @return true if given schema is an object array. */ public static boolean isObjectArrayType(@Nullable OasSchema schema) { + if (schema == null || !"array".equals(schema.type)) { return false; } + Object items = schema.items; if (items instanceof OasSchema oasSchema) { return isObjectType(oasSchema); @@ -109,7 +128,7 @@ public static List getSchemes(OasDocument openApiDoc) { public static OasSchema resolveSchema(OasDocument oasDocument, OasSchema schema) { if (isReferenceType(schema)) { - return getSchemaDefinitions(oasDocument).get(schema.$ref); + return getSchemaDefinitions(oasDocument).get(getReferenceName(schema.$ref)); } return schema; @@ -194,6 +213,16 @@ public static String getReferenceName(String reference) { public static Optional getSchema(OasResponse response) { return delegate(response, Oas20ModelHelper::getSchema, Oas30ModelHelper::getSchema); } + + public static Optional> getSchema(OasOperation oasOperation, OasResponse response, List acceptedMediaTypes) { + if (oasOperation instanceof Oas20Operation oas20Operation && response instanceof Oas20Response oas20Response) { + return Oas20ModelHelper.getSchema(oas20Operation, oas20Response, acceptedMediaTypes); + } else if (oasOperation instanceof Oas30Operation oas30Operation && response instanceof Oas30Response oas30Response) { + return Oas30ModelHelper.getSchema(oas30Operation, oas30Response, acceptedMediaTypes); + } + throw new IllegalArgumentException(String.format("Unsupported operation response type: %s", response.getClass())); + } + public static Optional getParameterSchema(OasParameter parameter) { return delegate(parameter, Oas20ModelHelper::getParameterSchema, Oas30ModelHelper::getParameterSchema); } @@ -219,22 +248,71 @@ public static Collection getResponseTypes(OasOperation operation, OasRes } /** - * Determines the appropriate response from an OAS (OpenAPI Specification) operation. - * The method looks for the response status code within the range 200 to 299 and returns - * the corresponding response if one is found. The first response in the list of responses, - * that satisfies the constraint will be returned. (TODO: see comment in Oas30ModelHelper) If none of the responses has a 2xx status code, - * the first response in the list will be returned. + * Determines the appropriate random response from an OpenAPI Specification operation based on the given status code. + * If a status code is specified, return the response for the specified status code. May be empty. + *

+ * If no exact match is found: + *

    + *
  • Fallback 1: Returns the 'default_' response if it exists.
  • + *
  • Fallback 2: Returns the first response object related to a 2xx status code that contains an acceptable schema for random message generation.
  • + *
  • Fallback 3: Returns the first response object related to a 2xx status code even without a schema. This is for operations that simply do not return anything else than a status code.
  • + *
  • Fallback 4: Returns the first response in the list of responses, no matter which schema.
  • + *
* + * Note that for Fallback 3 and 4, it is very likely, that there is no schema specified. It is expected, that an empty response is a viable response in these cases. + * + * @param openApiDoc The OpenAPI document containing the API specifications. + * @param operation The OAS operation for which to determine the response. + * @param statusCode The specific status code to match against responses, or {@code null} to search for any acceptable response. + * @param accept The mediatype accepted by the request + * @return An {@link Optional} containing the resolved {@link OasResponse} if found, or {@link Optional#empty()} otherwise. */ - public static Optional getResponseForRandomGeneration(OasDocument openApiDoc, OasOperation operation) { - return delegate(openApiDoc, operation, Oas20ModelHelper::getResponseForRandomGeneration, Oas30ModelHelper::getResponseForRandomGeneration); - } + public static Optional getResponseForRandomGeneration(OasDocument openApiDoc, OasOperation operation, @Nullable String statusCode, @Nullable String accept) { - /** - * Returns the response type used for random response generation. See specific helper implementations for detail. - */ - public static Optional getResponseContentTypeForRandomGeneration(OasDocument openApiDoc, OasOperation operation) { - return delegate(openApiDoc, operation, Oas20ModelHelper::getResponseContentTypeForRandomGeneration, Oas30ModelHelper::getResponseContentTypeForRandomGeneration); + if (operation.responses == null || operation.responses.getResponses().isEmpty()) { + return Optional.empty(); + } + + // Resolve all references + Map responseMap = OasModelHelper.resolveResponses(openApiDoc, + operation.responses); + + // For a given status code, do not fall back + if (statusCode != null) { + return Optional.ofNullable(responseMap.get(statusCode)); + } + + // Only accept responses that provide a schema for which we can actually provide a random message + Predicate acceptedSchemas = resp -> getSchema(operation, resp, accept != null ? singletonList(accept) : DEFAULT_ACCEPTED_MEDIA_TYPES).isPresent(); + + // Fallback 1: Pick the default if it exists + Optional response = Optional.ofNullable(responseMap.get(DEFAULT_)); + + if (response.isEmpty()) { + // Fallback 2: Pick the response object related to the first 2xx, providing an accepted schema + response = responseMap.values().stream() + .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) + .map(OasResponse.class::cast) + .filter(acceptedSchemas) + .findFirst(); + } + + if (response.isEmpty()) { + // Fallback 3: Pick the response object related to the first 2xx (even without schema) + response = responseMap.values().stream() + .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) + .map(OasResponse.class::cast) + .findFirst(); + } + + if (response.isEmpty()) { + // Fallback 4: Pick the first response no matter which schema + response = operation.responses.getResponses().stream() + .map(resp -> responseMap.get(resp.getStatusCode())) + .filter(Objects::nonNull).findFirst(); + } + + return response; } /** @@ -329,6 +407,8 @@ private static T delegate(OasOperation operation, FunctionThis method iterates over the responses contained in the {@link OasResponses} object. If a response has a reference - * (indicated by a non-null {@code $ref} field), the reference is resolved using the {@code responseResolver} function. Other responses - * will be added to the result list as is.

+ *

+ * This method iterates over the responses contained in the {@link OasResponses} object. If a response has a reference + * (indicated by a non-null {@code $ref} field), it resolves the reference and adds the resolved response to the result list. + * Non-referenced responses are added to the result list as-is. The resulting map includes the default response under + * the key {@link OasModelHelper#DEFAULT_}, if it exists. + *

* - * @param responses the {@link OasResponses} instance containing the responses to be resolved. - * @param responseResolver a {@link Function} that takes a reference string and returns the corresponding {@link OasResponse}. + * @param responses the {@link OasResponses} instance containing the responses to be resolved. * @return a {@link List} of {@link OasResponse} instances, where all references have been resolved. */ - public static List resolveResponses(OasResponses responses, Function responseResolver) { + private static Map resolveResponses(OasDocument openApiDoc, OasResponses responses) { + + Function responseResolver = getResponseResolver( + openApiDoc); - List responseList = new ArrayList<>(); + Map responseMap = new HashMap<>(); for (OasResponse response : responses.getResponses()) { if (response.$ref != null) { OasResponse resolved = responseResolver.apply(getReferenceName(response.$ref)); if (resolved != null) { - responseList.add(resolved); + // Note that we need to get the statusCode from the ref, as the referenced does not know about it. + responseMap.put(response.getStatusCode(), resolved); } } else { - responseList.add(response); + responseMap.put(response.getStatusCode(), response); } } - return responseList; + if (responses.default_ != null) { + if (responses.default_.$ref != null) { + OasResponse resolved = responseResolver.apply(responses.default_.$ref); + if (resolved != null) { + responseMap.put(DEFAULT_, resolved); + } + } else { + responseMap.put(DEFAULT_, responses.default_); + } + } + + return responseMap; + } + + private static Function getResponseResolver( + OasDocument openApiDoc) { + return delegate(openApiDoc, + (Function>) doc -> (responseRef -> doc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))), + (Function>) doc -> (responseRef -> doc.components.responses.get(OasModelHelper.getReferenceName(responseRef)))); + } + + /** + * Traverses the OAS document and applies the given visitor to each OAS operation found. + * This method uses the provided {@link OasOperationVisitor} to process each operation within the paths of the OAS document. + * + * @param oasDocument the OAS document to traverse + * @param visitor the visitor to apply to each OAS operation + */ + public static void visitOasOperations(OasDocument oasDocument, OasOperationVisitor visitor) { + if (oasDocument == null || visitor == null) { + return; + } + + oasDocument.paths.accept(new CombinedVisitorAdapter() { + + @Override + public void visitPaths(OasPaths oasPaths) { + oasPaths.getPathItems().forEach(oasPathItem -> oasPathItem.accept(this)); + } + + @Override + public void visitPathItem(OasPathItem oasPathItem) { + String path = oasPathItem.getPath(); + + if (StringUtils.isEmpty(path)) { + return; + } + + getOperationMap(oasPathItem).values() + .forEach(oasOperation -> visitor.visit(oasPathItem, oasOperation)); + + } + }); + } + + /** + * Resolves and normalizes a list of accepted media types. If the input list is null, + * returns null. Otherwise, splits each media type string by comma, trims whitespace, + * and collects them into a list of normalized types. + * + * @param acceptedMediaTypes List of accepted media types, may be null. + * @return Normalized list of media types, or null if input is null. + */ + public static List resolveAllTypes(@Nullable List acceptedMediaTypes) { + if (acceptedMediaTypes == null) { + return acceptedMediaTypes; + } + + return acceptedMediaTypes.stream() + .flatMap(types -> Arrays.stream(types.split(","))).map(String::trim).toList(); } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java new file mode 100644 index 0000000000..85e4cfbb35 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java @@ -0,0 +1,28 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.model; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasPathItem; + +/** + * The {@code OasOperationVisitor} interface defines a visitor pattern for operations on OAS (OpenAPI Specification) path items and operations. + */ +public interface OasOperationVisitor { + + void visit(OasPathItem oasPathItem, OasOperation oasOperation); +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java new file mode 100644 index 0000000000..7d943af929 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java @@ -0,0 +1,39 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.model; + +import static java.lang.String.format; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import org.citrusframework.openapi.OpenApiUtils; + +/** + * Adapts the different paths associated with an OpenAPI operation to the {@link OasOperation}. + * This record holds the API path, context path, full path, and the associated {@link OasOperation} object. + * + * @param apiPath The API path for the operation. + * @param contextPath The context path in which the API is rooted. + * @param fullPath The full path combining context path and API path. + * @param operation The {@link OasOperation} object representing the operation details. + */ +public record OperationPathAdapter(String apiPath, String contextPath, String fullPath, OasOperation operation) { + + @Override + public String toString() { + return format("%s (%s)",OpenApiUtils.getMethodPath(operation.getMethod(), apiPath), operation.operationId); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java index f4480dffff..dd5780fdd0 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java @@ -18,7 +18,6 @@ import io.apicurio.datamodels.openapi.models.OasHeader; import io.apicurio.datamodels.openapi.models.OasParameter; -import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v2.models.Oas20Document; import io.apicurio.datamodels.openapi.v2.models.Oas20Header; @@ -27,6 +26,7 @@ import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; import io.apicurio.datamodels.openapi.v2.models.Oas20SchemaDefinition; +import java.util.Arrays; import jakarta.annotation.Nullable; import java.util.Collection; import java.util.Collections; @@ -34,8 +34,8 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import org.citrusframework.openapi.model.OasAdapter; import org.citrusframework.openapi.model.OasModelHelper; -import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -56,12 +56,12 @@ public static List getSchemes(Oas20Document openApiDoc) { public static String getBasePath(Oas20Document openApiDoc) { return Optional.ofNullable(openApiDoc.basePath) - .map(basePath -> basePath.startsWith("/") ? basePath : "/" + basePath).orElse("/"); + .map(basePath -> basePath.startsWith("/") ? basePath : "/" + basePath).orElse("/"); } public static Map getSchemaDefinitions(Oas20Document openApiDoc) { if (openApiDoc == null - || openApiDoc.definitions == null) { + || openApiDoc.definitions == null) { return Collections.emptyMap(); } @@ -72,6 +72,23 @@ public static Optional getSchema(Oas20Response response) { return Optional.ofNullable(response.schema); } + public static Optional> getSchema(Oas20Operation oas20Operation, Oas20Response response, List acceptedMediaTypes) { + + acceptedMediaTypes = OasModelHelper.resolveAllTypes(acceptedMediaTypes); + acceptedMediaTypes = acceptedMediaTypes != null ? acceptedMediaTypes : OasModelHelper.DEFAULT_ACCEPTED_MEDIA_TYPES; + + OasSchema selectedSchema = response.schema; + String selectedMediaType = null; + if (oas20Operation.produces != null && !oas20Operation.produces.isEmpty()) { + selectedMediaType = acceptedMediaTypes.stream() + .filter(type -> !isFormDataMediaType(type)) + .filter(type -> oas20Operation.produces.contains(type)).findFirst() + .orElse(null); + } + + return selectedSchema == null && selectedMediaType == null ? Optional.empty() : Optional.of(new OasAdapter<>(selectedSchema, selectedMediaType)); + } + public static Optional getRequestBodySchema(@Nullable Oas20Document ignoredOpenApiDoc, Oas20Operation operation) { if (operation.parameters == null) { return Optional.empty(); @@ -80,8 +97,8 @@ public static Optional getRequestBodySchema(@Nullable Oas20Document i final List operationParameters = operation.parameters; Optional body = operationParameters.stream() - .filter(p -> "body".equals(p.in) && p.schema != null) - .findFirst(); + .filter(p -> "body".equals(p.in) && p.schema != null) + .findFirst(); return body.map(oasParameter -> (OasSchema) oasParameter.schema); } @@ -94,67 +111,24 @@ public static Optional getRequestContentType(Oas20Operation operation) { return Optional.empty(); } - public static Collection getResponseTypes(Oas20Operation operation,@Nullable Oas20Response ignoredResponse) { + public static Collection getResponseTypes(Oas20Operation operation, @Nullable Oas20Response ignoredResponse) { if (operation == null) { return Collections.emptyList(); } return operation.produces; } - /** - * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, - * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. - * - * @param ignoredOpenApiDoc required to implement quasi interface but ignored in this implementation. - * @param operation - * @return - */ - public static Optional getResponseContentTypeForRandomGeneration(@Nullable Oas20Document ignoredOpenApiDoc, Oas20Operation operation) { - if (operation.produces != null) { - for (String mediaType : operation.produces) { - if (MediaType.APPLICATION_JSON_VALUE.equals(mediaType)) { - return Optional.of(mediaType); - } - } - } - - return Optional.empty(); - } - - public static Optional getResponseForRandomGeneration(Oas20Document openApiDoc, Oas20Operation operation) { - - if (operation.responses == null) { - return Optional.empty(); - } - - List responses = OasModelHelper.resolveResponses(operation.responses, - responseRef -> openApiDoc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))); - - // Pick the response object related to the first 2xx return code found - Optional response = responses.stream() - .filter(Oas20Response.class::isInstance) - .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) - .map(OasResponse.class::cast) - .filter(res -> OasModelHelper.getSchema(res).isPresent()) - .findFirst(); - - if (response.isEmpty()) { - // TODO: Although the Swagger specification states that at least one successful response SHOULD be specified in the responses, - // the Petstore API does not. It only specifies error responses. As a result, we currently only return a successful response if one is found. - // If no successful response is specified, we return an empty response instead, to be backwards compatible. - response = Optional.empty(); - } - - return response; - } - public static Map getHeaders(Oas20Response response) { if (response.headers == null) { return Collections.emptyMap(); } return response.headers.getHeaders().stream() - .collect(Collectors.toMap(OasHeader::getName, Oas20ModelHelper::getHeaderSchema)); + .collect(Collectors.toMap(OasHeader::getName, Oas20ModelHelper::getHeaderSchema)); + } + + private static boolean isFormDataMediaType(String type) { + return Arrays.asList("application/x-www-form-urlencoded", "multipart/form-data").contains(type); } /** diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java index 3b2c1995bc..23e4c20061 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java @@ -18,7 +18,6 @@ import io.apicurio.datamodels.core.models.common.Server; import io.apicurio.datamodels.core.models.common.ServerVariable; -import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType; @@ -26,6 +25,7 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; import java.net.MalformedURLException; import java.net.URL; import java.util.Arrays; @@ -37,10 +37,10 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import org.citrusframework.openapi.model.OasAdapter; import org.citrusframework.openapi.model.OasModelHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -78,17 +78,17 @@ public static List getSchemes(Oas30Document openApiDoc) { } return openApiDoc.servers.stream() - .map(Oas30ModelHelper::resolveUrl) - .map(serverUrl -> { - try { - return new URL(serverUrl).getProtocol(); - } catch (MalformedURLException e) { - LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); - return null; - } - }) - .filter(Objects::nonNull) - .toList(); + .map(Oas30ModelHelper::resolveUrl) + .map(serverUrl -> { + try { + return new URL(serverUrl).getProtocol(); + } catch (MalformedURLException e) { + LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); + return null; + } + }) + .filter(Objects::nonNull) + .toList(); } public static String getBasePath(Oas30Document openApiDoc) { @@ -119,8 +119,8 @@ public static Map getSchemaDefinitions(Oas30Document openApiD } return openApiDoc.components.schemas.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue)); + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue)); } public static Optional getSchema(Oas30Response response) { @@ -130,11 +130,38 @@ public static Optional getSchema(Oas30Response response) { } return content.entrySet() - .stream() - .filter(entry -> !isFormDataMediaType(entry.getKey())) - .filter(entry -> entry.getValue().schema != null) - .map(entry -> (OasSchema) entry.getValue().schema) - .findFirst(); + .stream() + .filter(entry -> !isFormDataMediaType(entry.getKey())) + .filter(entry -> entry.getValue().schema != null) + .map(entry -> (OasSchema) entry.getValue().schema) + .findFirst(); + } + + public static Optional> getSchema( + Oas30Operation ignoredOas30Operation, Oas30Response response, List acceptedMediaTypes) { + + acceptedMediaTypes = OasModelHelper.resolveAllTypes(acceptedMediaTypes); + acceptedMediaTypes = acceptedMediaTypes != null ? acceptedMediaTypes : OasModelHelper.DEFAULT_ACCEPTED_MEDIA_TYPES; + + Map content = response.content; + if (content == null) { + return Optional.empty(); + } + + String selectedMediaType = null; + Oas30Schema selectedSchema = null; + for (String type : acceptedMediaTypes) { + if (!isFormDataMediaType(type)) { + Oas30MediaType oas30MediaType = content.get(type); + if (oas30MediaType != null) { + selectedMediaType = type; + selectedSchema = oas30MediaType.schema; + break; + } + } + } + + return selectedSchema == null && selectedMediaType == null ? Optional.empty() : Optional.of(new OasAdapter<>(selectedSchema, selectedMediaType)); } public static Optional getRequestBodySchema(Oas30Document openApiDoc, Oas30Operation operation) { @@ -145,8 +172,8 @@ public static Optional getRequestBodySchema(Oas30Document openApiDoc, Oas30RequestBody bodyToUse = operation.requestBody; if (openApiDoc.components != null - && openApiDoc.components.requestBodies != null - && bodyToUse.$ref != null) { + && openApiDoc.components.requestBodies != null + && bodyToUse.$ref != null) { bodyToUse = openApiDoc.components.requestBodies.get(OasModelHelper.getReferenceName(bodyToUse.$ref)); } @@ -155,12 +182,12 @@ public static Optional getRequestBodySchema(Oas30Document openApiDoc, } return bodyToUse.content.entrySet() - .stream() - .filter(entry -> !isFormDataMediaType(entry.getKey())) - .filter(entry -> entry.getValue().schema != null) - .findFirst() - .map(Map.Entry::getValue) - .map(oas30MediaType -> oas30MediaType.schema); + .stream() + .filter(entry -> !isFormDataMediaType(entry.getKey())) + .filter(entry -> entry.getValue().schema != null) + .findFirst() + .map(Map.Entry::getValue) + .map(oas30MediaType -> oas30MediaType.schema); } public static Optional getRequestContentType(Oas30Operation operation) { @@ -169,10 +196,10 @@ public static Optional getRequestContentType(Oas30Operation operation) { } return operation.requestBody.content.entrySet() - .stream() - .filter(entry -> entry.getValue().schema != null) - .map(Map.Entry::getKey) - .findFirst(); + .stream() + .filter(entry -> entry.getValue().schema != null) + .map(Map.Entry::getKey) + .findFirst(); } public static Collection getResponseTypes(Oas30Operation operation, Oas30Response response) { @@ -182,73 +209,15 @@ public static Collection getResponseTypes(Oas30Operation operation, Oas3 return response.content != null ? response.content.keySet() : Collections.emptyList(); } - /** - * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, - * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. - * - * @param openApiDoc - * @param operation - * @return - */ - public static Optional getResponseContentTypeForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { - Optional responseForRandomGeneration = getResponseForRandomGeneration( - openApiDoc, operation); - return responseForRandomGeneration.map( - Oas30Response.class::cast).flatMap(res -> res.content.entrySet() - .stream() - .filter(entry -> MediaType.APPLICATION_JSON_VALUE.equals(entry.getKey())) - .filter(entry -> entry.getValue().schema != null) - .map(Map.Entry::getKey) - .findFirst()); - } - - public static Optional getResponseForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { - if (operation.responses == null) { - return Optional.empty(); - } - - List responses = OasModelHelper.resolveResponses(operation.responses, - responseRef -> openApiDoc.components.responses.get(OasModelHelper.getReferenceName(responseRef))); - - // Pick the response object related to the first 2xx return code found - Optional response = responses.stream() - .filter(Oas30Response.class::isInstance) - .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) - .map(OasResponse.class::cast) - .filter(res -> OasModelHelper.getSchema(res).isPresent()) - .findFirst(); - - // No 2xx response given so pick the first one no matter what status code - if (response.isEmpty()) { - // TODO: This behavior differs from OAS2 and is very likely a bug because it may result in returning error messages. - // According to the specification, there MUST be at least one response, which SHOULD be a successful response. - // If the response is NOT A SUCCESSFUL one, we encounter an error case, which is likely not the intended behavior. - // The specification likely does not intend to define operations that always fail. On the other hand, it is not - // against the spec to NOT document an OK response that is empty. - // For testing purposes, note that the difference between OAS2 and OAS3 is evident in the Petstore API. - // The Petstore API specifies successful response codes for OAS3 but lacks these definitions for OAS2. - // Therefore, while tests pass for OAS3, they fail for OAS2. - // I would suggest to return an empty response in case we fail to resolve a good response, as in Oas2. - // In case of absence of a response an OK response will be sent as default. - response = responses.stream() - .filter(Oas30Response.class::isInstance) - .map(OasResponse.class::cast) - .filter(res -> OasModelHelper.getSchema(res).isPresent()) - .findFirst(); - } - - return response; - } - public static Map getRequiredHeaders(Oas30Response response) { if (response.headers == null) { return Collections.emptyMap(); } return response.headers.entrySet() - .stream() - .filter(entry -> Boolean.TRUE.equals(entry.getValue().required)) - .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); + .stream() + .filter(entry -> Boolean.TRUE.equals(entry.getValue().required)) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); } public static Map getHeaders(Oas30Response response) { @@ -257,8 +226,8 @@ public static Map getHeaders(Oas30Response response) { } return response.headers.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().schema)); } private static boolean isFormDataMediaType(String type) { diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java new file mode 100644 index 0000000000..b640adb365 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java @@ -0,0 +1,60 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.validation.ValidationProcessor; + +/** + * {@code ValidationProcessor} that facilitates the use of Atlassian's Swagger Request Validator, + * and delegates validation of OpenApi requests to instances of {@link OpenApiRequestValidator}. + */ +public class OpenApiRequestValidationProcessor implements + ValidationProcessor { + + private final OpenApiSpecification openApiSpecification; + + private final String operationId; + + private boolean enabled = true; + + public OpenApiRequestValidationProcessor(OpenApiSpecification openApiSpecification, + String operationId) { + this.operationId = operationId; + this.openApiSpecification = openApiSpecification; + } + + + @Override + public void validate(Message message, TestContext context) { + + if (!enabled || !(message instanceof HttpMessage httpMessage)) { + return; + } + openApiSpecification.getOperation( + operationId, context).ifPresent(operationPathAdapter -> + openApiSpecification.getRequestValidator().ifPresent(openApiRequestValidator -> + openApiRequestValidator.validateRequest(operationPathAdapter, httpMessage))); + } + + public void setEnabled(boolean b) { + this.enabled = b; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java new file mode 100644 index 0000000000..6948c793d8 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java @@ -0,0 +1,97 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.report.ValidationReport; +import java.util.ArrayList; +import java.util.Collection; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.model.OperationPathAdapter; + +/** + * Specific validator that uses atlassian and is responsible for validating HTTP requests + * against an OpenAPI specification using the provided {@code OpenApiInteractionValidator}. + */ +public class OpenApiRequestValidator extends OpenApiValidator { + + public OpenApiRequestValidator(OpenApiInteractionValidator openApiInteractionValidator) { + super(openApiInteractionValidator, isRequestValidationEnabledlobally()); + } + + @Override + protected String getType() { + return "request"; + } + + public void validateRequest(OperationPathAdapter operationPathAdapter, + HttpMessage requestMessage) { + + if (enabled && openApiInteractionValidator != null) { + ValidationReport validationReport = openApiInteractionValidator.validateRequest( + createRequestFromMessage(operationPathAdapter, requestMessage)); + if (validationReport.hasErrors()) { + throw new ValidationException( + constructErrorMessage(operationPathAdapter, validationReport)); + } + } + } + + Request createRequestFromMessage(OperationPathAdapter operationPathAdapter, + HttpMessage httpMessage) { + var payload = httpMessage.getPayload(); + + String contextPath = operationPathAdapter.contextPath(); + String requestUri = (String) httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI); + if (contextPath != null && requestUri.startsWith(contextPath)) { + requestUri = requestUri.substring(contextPath.length()); + } + + SimpleRequest.Builder requestBuilder = new SimpleRequest.Builder( + httpMessage.getRequestMethod().asHttpMethod().name(), requestUri + ); + + if (payload != null) { + requestBuilder = requestBuilder.withBody(payload.toString()); + } + + SimpleRequest.Builder finalRequestBuilder = requestBuilder; + finalRequestBuilder.withAccept(httpMessage.getAccept()); + + httpMessage.getQueryParams() + .forEach((key, value) -> finalRequestBuilder.withQueryParam(key, new ArrayList<>( + value))); + + httpMessage.getHeaders().forEach((key, value) -> { + if (value instanceof Collection) { + ((Collection) value).forEach( v -> finalRequestBuilder.withHeader(key, v != null ? v.toString() : null)); + } else { + finalRequestBuilder.withHeader(key, + value != null ? value.toString() : null); + } + }); + + return requestBuilder.build(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java new file mode 100644 index 0000000000..18754062f1 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java @@ -0,0 +1,58 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.validation.ValidationProcessor; + +/** + * {@code ValidationProcessor} that delegates validation of OpenApi responses to instances of {@link OpenApiResponseValidator}. + */ +public class OpenApiResponseValidationProcessor implements + ValidationProcessor { + + private final OpenApiSpecification openApiSpecification; + + private final String operationId; + + private boolean enabled = true; + + public OpenApiResponseValidationProcessor(OpenApiSpecification openApiSpecification, String operationId) { + this.operationId = operationId; + this.openApiSpecification = openApiSpecification; + } + + @Override + public void validate(Message message, TestContext context) { + + if (!enabled || !(message instanceof HttpMessage httpMessage)) { + return; + } + + openApiSpecification.getOperation( + operationId, context).ifPresent(operationPathAdapter -> + openApiSpecification.getResponseValidator().ifPresent(openApiResponseValidator -> + openApiResponseValidator.validateResponse(operationPathAdapter, httpMessage))); + } + + public void setEnabled(boolean b) { + this.enabled = b; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java new file mode 100644 index 0000000000..db4a41e375 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java @@ -0,0 +1,77 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request.Method; +import com.atlassian.oai.validator.model.Response; +import com.atlassian.oai.validator.model.SimpleResponse; +import com.atlassian.oai.validator.report.ValidationReport; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.springframework.http.HttpStatusCode; + +/** + * Specific validator, that facilitates the use of Atlassian's Swagger Request Validator, + * and delegates validation of OpenApi requests to instances of {@link OpenApiRequestValidator}. + */ +public class OpenApiResponseValidator extends OpenApiValidator { + + public OpenApiResponseValidator(OpenApiInteractionValidator openApiInteractionValidator) { + super(openApiInteractionValidator, isResponseValidationEnabledGlobally()); + } + + @Override + protected String getType() { + return "response"; + } + + public void validateResponse(OperationPathAdapter operationPathAdapter, HttpMessage httpMessage) { + + if (enabled && openApiInteractionValidator != null) { + HttpStatusCode statusCode = httpMessage.getStatusCode(); + Response response = createResponseFromMessage(httpMessage, + statusCode != null ? statusCode.value() : null); + + ValidationReport validationReport = openApiInteractionValidator.validateResponse( + operationPathAdapter.apiPath(), + Method.valueOf(operationPathAdapter.operation().getMethod().toUpperCase()), + response); + if (validationReport.hasErrors()) { + throw new ValidationException(constructErrorMessage(operationPathAdapter, validationReport)); + } + } + } + + Response createResponseFromMessage(HttpMessage message, Integer statusCode) { + var payload = message.getPayload(); + SimpleResponse.Builder responseBuilder = new SimpleResponse.Builder(statusCode); + + if (payload != null) { + responseBuilder = responseBuilder.withBody(payload.toString()); + } + + SimpleResponse.Builder finalResponseBuilder = responseBuilder; + message.getHeaders().forEach((key, value) -> finalResponseBuilder.withHeader(key, + value != null ? value.toString() : null)); + + return responseBuilder.build(); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java new file mode 100644 index 0000000000..a6bc2e98c8 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java @@ -0,0 +1,61 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.report.ValidationReport; +import org.citrusframework.openapi.model.OperationPathAdapter; + +public abstract class OpenApiValidator { + + protected final OpenApiInteractionValidator openApiInteractionValidator; + + protected boolean enabled; + + protected OpenApiValidator(OpenApiInteractionValidator openApiInteractionValidator, boolean enabled) { + this.openApiInteractionValidator = openApiInteractionValidator; + this.enabled = enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + protected abstract String getType(); + + /** + * Constructs the error message of a failed validation based on the processing report passed + * from {@link ValidationReport}. + * + * @param report The report containing the error message + * @return A string representation of all messages contained in the report + */ + protected String constructErrorMessage(OperationPathAdapter operationPathAdapter, + ValidationReport report) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("OpenApi "); + stringBuilder.append(getType()); + stringBuilder.append(" validation failed for operation: "); + stringBuilder.append(operationPathAdapter); + report.getMessages().forEach(message -> stringBuilder.append("\n\t").append(message)); + return stringBuilder.toString(); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java new file mode 100644 index 0000000000..9d811711f6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java @@ -0,0 +1,171 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class OpenApiPathRegistryTest { + + private static final String[] SEGMENTS = {"api", "v1", "pet", "user", "order", "product", + "category", "service", "data"}; + private static final String VARIABLE_TEMPLATE = "{%s}"; + private static final String[] VARIABLES = {"id", "userId", "orderId", "productId", + "categoryId"}; + + public static List generatePaths(int numberOfPaths) { + List paths = new ArrayList<>(); + Random random = new Random(); + + Set allGenerated = new HashSet<>(); + while (allGenerated.size() < numberOfPaths) { + int numberOfSegments = 1 + random.nextInt(7); // 1 to 7 segments + StringBuilder pathBuilder = new StringBuilder("/api/v1"); + + int nids = 0; + for (int j = 0; j < numberOfSegments; j++) { + if (nids < 2 && nids < numberOfSegments - 1 && random.nextBoolean()) { + nids++; + // Add a segment with a variable + pathBuilder.append("/").append(String.format(VARIABLE_TEMPLATE, + VARIABLES[random.nextInt(VARIABLES.length)])); + } else { + // Add a fixed segment + pathBuilder.append("/").append(SEGMENTS[random.nextInt(SEGMENTS.length)]); + } + } + + String path = pathBuilder.toString(); + if (!allGenerated.contains(path)) { + paths.add(path); + allGenerated.add(path); + } + } + return paths; + } + + @Test + public void insertShouldSucceedOnSameValue() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root")); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root")); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root"); + } + + @Test + public void insertShouldFailOnSamePathWithDifferentValue() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root1")); + assertFalse(openApiPathRegistry.insert("/s1/s2", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root1"); + } + + @Test + public void searchShouldSucceedOnPartialPathMatchWithDifferentVariables() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}", "root1")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id2}/s4/{id1}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2/1111"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/123/s4/222"), "root2"); + + openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id2}", "root1")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}/s4/{id2}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2/1111"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/123/s4/222"), "root2"); + } + + @Test + public void insertShouldFailOnMatchingPathWithDifferentValue() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root1")); + assertFalse(openApiPathRegistry.insert("/s1/{id1}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root1"); + assertNull(openApiPathRegistry.search("/s1/111")); + + assertTrue(openApiPathRegistry.insert("/s1/s2/s3/{id2}", "root3")); + assertFalse(openApiPathRegistry.insert("/s1/{id1}/s3/{id2}", "root4")); + assertEquals(openApiPathRegistry.search("/s1/s2/s3/123"), "root3"); + assertEquals(openApiPathRegistry.search("/s1/s2/s3/456"), "root3"); + assertNull(openApiPathRegistry.search("/s1/111/s3/111")); + + openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/{id1}", "root2")); + assertFalse(openApiPathRegistry.insert("/s1/s2", "root1")); + assertEquals(openApiPathRegistry.search("/s1/111"), "root2"); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root2"); + + assertTrue(openApiPathRegistry.insert("/s1/{id1}/s3/{id2}", "root3")); + assertFalse(openApiPathRegistry.insert("/s1/s2/s3/{id2}", "root4")); + assertEquals(openApiPathRegistry.search("/s1/5678/s3/1234"), "root3"); + assertEquals(openApiPathRegistry.search("/s1/s2/s3/1234"), "root3"); + } + + @Test + public void insertShouldNotOverwriteNested() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}", "root1")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}/s3/{id2}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2/123"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/1233/s3/121"), "root2"); + + openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}/s3/{id2}", "root2")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}", "root1")); + assertEquals(openApiPathRegistry.search("/s1/s2/123"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/1233/s3/121"), "root2"); + } + + @Test + public void randomAccess() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + + int numberOfPaths = 1000; // Specify the number of paths you want to generate + List paths = generatePaths(numberOfPaths); + + Map pathToValueMap = paths.stream() + .collect(Collectors.toMap(path -> path, k -> k.replaceAll("\\{[a-zA-Z]*}", "1111"))); + paths.removeIf(path -> !openApiPathRegistry.insert(path, pathToValueMap.get(path))); + + Random random = new Random(); + int[] indexes = new int[1000]; + for (int i = 0; i < 1000; i++) { + indexes[i] = random.nextInt(paths.size() - 1); + } + + for (int i = 0; i < 1000; i++) { + String path = paths.get(indexes[i]); + String realPath = pathToValueMap.get(path); + String result = openApiPathRegistry.search(realPath); + Assert.assertNotNull(result, + "No result for real path " + realPath + " expected a match by path " + path); + Assert.assertEquals(result, realPath); + } + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java index 3c449ff5fe..9185cc6081 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java @@ -16,35 +16,105 @@ package org.citrusframework.openapi; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; import java.util.List; +import java.util.Optional; +import org.citrusframework.spi.Resource; import org.testng.annotations.Test; -@Test public class OpenApiRepositoryTest { - public static final String ROOT = "/root"; + private static final String ROOT = "/root"; - public void initializeOpenApiRepository() { + @Test + public void shouldInitializeOpenApiRepository() { OpenApiRepository openApiRepository = new OpenApiRepository(); openApiRepository.setRootContextPath(ROOT); - openApiRepository.setLocations(List.of("org/citrusframework/openapi/petstore/petstore**.json")); + openApiRepository.setLocations( + List.of("org/citrusframework/openapi/petstore/petstore**.json")); openApiRepository.initialize(); List openApiSpecifications = openApiRepository.getOpenApiSpecifications(); assertEquals(openApiRepository.getRootContextPath(), ROOT); assertNotNull(openApiSpecifications); - assertEquals(openApiSpecifications.size(),3); + assertEquals(openApiSpecifications.size(), 3); assertEquals(openApiSpecifications.get(0).getRootContextPath(), ROOT); assertEquals(openApiSpecifications.get(1).getRootContextPath(), ROOT); - assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(0))); - assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(1))); - assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(2))); + assertTrue( + SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(0))); + assertTrue( + SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(1))); + assertTrue( + SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(2))); } + + @Test + public void shouldResolveResourceAliasFromFile() { + File fileMock = mock(); + doReturn("MyApi.json").when(fileMock).getName(); + Resource resourceMock = mock(); + doReturn(fileMock).when(resourceMock).getFile(); + + Optional alias = OpenApiRepository.determineResourceAlias(resourceMock); + assertTrue(alias.isPresent()); + assertEquals(alias.get(), "MyApi.json"); + } + + @Test + public void shouldResolveResourceAliasFromUrl() throws MalformedURLException { + URL urlMock = mock(); + doReturn("/C:/segment1/segment2/MyApi.json").when(urlMock).getPath(); + Resource resourceMock = mock(); + doThrow(new RuntimeException("Forced Exception")).when(resourceMock).getFile(); + doReturn(urlMock).when(resourceMock).getURL(); + + Optional alias = OpenApiRepository.determineResourceAlias(resourceMock); + assertTrue(alias.isPresent()); + assertEquals(alias.get(), "MyApi.json"); + } + + @Test + public void shouldSetAndProvideProperties() { + // Given + OpenApiRepository openApiRepository = new OpenApiRepository(); + + // When + openApiRepository.setResponseValidationEnabled(true); + openApiRepository.setRequestValidationEnabled(true); + openApiRepository.setRootContextPath("/root"); + openApiRepository.setLocations(List.of("l1", "l2")); + + // Then + assertTrue(openApiRepository.isResponseValidationEnabled()); + assertTrue(openApiRepository.isRequestValidationEnabled()); + assertEquals(openApiRepository.getRootContextPath(), "/root"); + assertEquals(openApiRepository.getLocations(), List.of("l1", "l2")); + + // When + openApiRepository.setResponseValidationEnabled(false); + openApiRepository.setRequestValidationEnabled(false); + openApiRepository.setRootContextPath("/otherRoot"); + openApiRepository.setLocations(List.of("l3", "l4")); + + // Then + assertFalse(openApiRepository.isResponseValidationEnabled()); + assertFalse(openApiRepository.isRequestValidationEnabled()); + assertEquals(openApiRepository.getRootContextPath(), "/otherRoot"); + assertEquals(openApiRepository.getLocations(), List.of("l3", "l4")); + + } + } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java new file mode 100644 index 0000000000..c96cd5f5b6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java @@ -0,0 +1,196 @@ +package org.citrusframework.openapi; + +import static org.citrusframework.openapi.OpenApiSettings.GENERATE_OPTIONAL_FIELDS_PROPERTY; +import static org.citrusframework.openapi.OpenApiSettings.REQUEST_VALIDATION_ENABLED_PROPERTY; +import static org.citrusframework.openapi.OpenApiSettings.RESPONSE_VALIDATION_ENABLED_PROPERTY; +import static org.citrusframework.openapi.OpenApiSettings.VALIDATE_OPTIONAL_FIELDS_ENV; +import static org.citrusframework.openapi.OpenApiSettings.VALIDATE_OPTIONAL_FIELDS_PROPERTY; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; + +public class OpenApiSettingsTest { + + private final EnvironmentVariables environmentVariables = new EnvironmentVariables(); + + private static final boolean REQUEST_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isRequestValidationEnabledlobally(); + + private static final boolean RESPONSE_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isResponseValidationEnabledGlobally(); + + private static final boolean VALIDATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY = OpenApiSettings.isValidateOptionalFieldsGlobally(); + + private static final boolean GENERATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY = OpenApiSettings.isGenerateOptionalFieldsGlobally(); + + @BeforeMethod + public void beforeMethod() { + System.clearProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY); + System.clearProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY); + System.clearProperty(REQUEST_VALIDATION_ENABLED_PROPERTY); + System.clearProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY); + } + + @AfterMethod + public void afterMethod() throws Exception { + environmentVariables.teardown(); + + if (!GENERATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY) { + System.clearProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY); + } else { + System.setProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, "true"); + } + + if (!VALIDATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY) { + System.clearProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY); + } else { + System.setProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, "true"); + } + + if (!REQUEST_VALIDATION_ENABLED_GLOBALLY) { + System.clearProperty(REQUEST_VALIDATION_ENABLED_PROPERTY); + } else { + System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "true"); + } + + if (!RESPONSE_VALIDATION_ENABLED_GLOBALLY) { + System.clearProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY); + } else { + System.setProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY, "true"); + } + } + + @Test + public void testRequestValidationEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "true"); + assertTrue(OpenApiSettings.isRequestValidationEnabledlobally()); + } + + @Test + public void testRequestValidationDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "false"); + assertFalse(OpenApiSettings.isRequestValidationEnabledlobally()); + } + + @Test + public void testRequestValidationEnabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.REQUEST_VALIDATION_ENABLED_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isRequestValidationEnabledlobally()); + } + + @Test + public void testRequestValidationDisabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.REQUEST_VALIDATION_ENABLED_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isRequestValidationEnabledlobally()); + } + + @Test + public void testRequestValidationEnabledByDefault() { + assertTrue(OpenApiSettings.isRequestValidationEnabledlobally()); + } + + @Test + public void testResponseValidationEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY, "true"); + assertTrue(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY, "false"); + assertFalse(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationEnabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.RESPONSE_VALIDATION_ENABLED_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationDisabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.RESPONSE_VALIDATION_ENABLED_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationEnabledByDefault() { + assertTrue(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testGenerateOptionalFieldsEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, "true"); + assertTrue(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, "false"); + assertFalse(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsEnabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.GENERATE_OPTIONAL_FIELDS_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsDisabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.GENERATE_OPTIONAL_FIELDS_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsEnabledByDefault() { + assertTrue(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, "true"); + assertTrue(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, "false"); + assertFalse(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsEnabledByEnvVar() throws Exception { + environmentVariables.set(VALIDATE_OPTIONAL_FIELDS_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsDisabledByEnvVar() throws Exception { + environmentVariables.set(VALIDATE_OPTIONAL_FIELDS_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsEnabledByDefault() { + assertTrue(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java new file mode 100644 index 0000000000..6693078ef2 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java @@ -0,0 +1,49 @@ +package org.citrusframework.openapi; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OpenApiSpecificationAdapterTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private Object entityMock; + + private OpenApiSpecificationAdapter openApiSpecificationAdapter; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void setUp() { + mockCloseable = MockitoAnnotations.openMocks(this); + openApiSpecificationAdapter = new OpenApiSpecificationAdapter<>(openApiSpecificationMock, entityMock); + } + + @AfterMethod + public void tearDown() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldProvideOpenApiSpecification() { + OpenApiSpecification specification = openApiSpecificationAdapter.getOpenApiSpecification(); + assertNotNull(specification, "OpenApiSpecification should not be null"); + assertEquals(specification, openApiSpecificationMock, "OpenApiSpecification should match the mock"); + } + + @Test + public void shouldProvideEntity() { + Object entity = openApiSpecificationAdapter.getEntity(); + assertNotNull(entity, "Entity should not be null"); + assertEquals(entity, entityMock, "Entity should match the mock"); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java new file mode 100644 index 0000000000..5a8cb34c45 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java @@ -0,0 +1,387 @@ +package org.citrusframework.openapi; + +import io.apicurio.datamodels.openapi.models.OasDocument; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.HttpsURLConnection; +import org.citrusframework.context.TestContext; +import org.citrusframework.endpoint.EndpointConfiguration; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.http.client.HttpClient; +import org.citrusframework.http.client.HttpEndpointConfiguration; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.spi.ReferenceResolver; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources.ClasspathResource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.net.URL; +import java.util.Optional; + +import static org.citrusframework.util.FileUtils.readToString; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +public class OpenApiSpecificationTest { + + + private static final String PING_API_HTTP_URL_STRING = "http://org.citrus.example.com/ping-api.yaml"; + + private static final String PING_API_HTTPS_URL_STRING = "https://org.citrus.example.com/ping-api.yaml"; + + private static final String PING_OPERATION_ID = "doPing"; + + private static final String PONG_OPERATION_ID = "doPong"; + + private static String PING_API_STRING; + + @Mock + private TestContext testContextMock; + + @Mock + private HttpClient httpClient; + + @Mock + private ReferenceResolver referenceResolverMock; + + @Mock + private HttpEndpointConfiguration endpointConfigurationMock; + + private AutoCloseable mockCloseable; + + @InjectMocks + private OpenApiSpecification openApiSpecification; + + @BeforeClass + public void beforeClass() throws IOException { + PING_API_STRING = readToString( + new ClasspathResource( + "classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + } + @BeforeMethod + public void setUp() { + + mockCloseable = MockitoAnnotations.openMocks(this); + + testContextMock.setReferenceResolver(referenceResolverMock); + } + + @AfterMethod + public void tearDown() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldInitializeFromSpecUrl() { + + // When + OpenApiSpecification specification = OpenApiSpecification.from(PING_API_HTTP_URL_STRING); + + // Then + assertNotNull(specification); + assertEquals(specification.getSpecUrl(), PING_API_HTTP_URL_STRING); + assertTrue(specification.getRequestValidator().isEmpty()); + assertTrue(specification.getResponseValidator().isEmpty()); + + } + + @DataProvider(name = "protocollDataProvider") + public static Object[][] protocolls() { + return new Object[][] {{PING_API_HTTP_URL_STRING}, {PING_API_HTTPS_URL_STRING}}; + } + + @Test(dataProvider = "protocollDataProvider") + public void shouldInitializeFromUrl(String urlString) throws Exception { + // Given + URL urlMock = mockUrlConnection(urlString); + + // When + OpenApiSpecification specification = OpenApiSpecification.from(urlMock); + + // Then + assertEquals(specification.getSpecUrl(), urlString); + assertPingApi(specification); + } + + private void assertPingApi(OpenApiSpecification specification) { + assertNotNull(specification); + assertTrue(specification.getRequestValidator().isPresent()); + assertTrue(specification.getResponseValidator().isPresent()); + Optional pingOperationPathAdapter = specification.getOperation( + PING_OPERATION_ID, + testContextMock); + assertTrue(pingOperationPathAdapter.isPresent()); + assertEquals(pingOperationPathAdapter.get().apiPath(), "/ping/{id}"); + assertNull(pingOperationPathAdapter.get().contextPath()); + assertEquals(pingOperationPathAdapter.get().fullPath(), "/ping/{id}"); + + Optional pongOperationPathAdapter = specification.getOperation( + PONG_OPERATION_ID, + testContextMock); + assertTrue(pongOperationPathAdapter.isPresent()); + assertEquals(pongOperationPathAdapter.get().apiPath(), "/pong/{id}"); + assertNull(pongOperationPathAdapter.get().contextPath()); + assertEquals(pongOperationPathAdapter.get().fullPath(), "/pong/{id}"); + } + + @Test + public void shouldInitializeFromResource() { + // Given + Resource resource= new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml"); + + // When + OpenApiSpecification specification = OpenApiSpecification.from(resource); + + // Then + assertNotNull(specification); + assertEquals(specification.getSpecUrl(), resource.getLocation()); + assertPingApi(specification); + } + + @Test + public void shouldReturnOpenApiDocWhenInitialized() { + //Given + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + OasDocument openApiDoc = specification.getOpenApiDoc(testContextMock); + + //When + OpenApiSpecification otherSpecification = new OpenApiSpecification(); + otherSpecification.setOpenApiDoc(openApiDoc); + OasDocument doc = otherSpecification.getOpenApiDoc(testContextMock); + + // Then + assertNotNull(doc); + assertEquals(doc, openApiDoc); + } + + @Test + public void shouldReturnEmptyOptionalWhenOperationIdIsNull() { + // When + Optional result = openApiSpecification.getOperation(null, + testContextMock); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + public void shouldReturnOperationWhenExists() { + // Given/When + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // Then + assertPingApi(specification); + } + + @Test + public void shouldInitializeDocumentWhenRequestingOperation() { + // Given/When + when(testContextMock.replaceDynamicContentInString(isA(String.class))).thenAnswer(answer-> + answer.getArgument(0) + ); + OpenApiSpecification specification = OpenApiSpecification.from("classpath:org/citrusframework/openapi/ping/ping-api.yaml"); + + // Then + Optional pingOperationPathAdapter = specification.getOperation( + PING_OPERATION_ID, + testContextMock); + assertTrue(pingOperationPathAdapter.isPresent()); + assertEquals(pingOperationPathAdapter.get().apiPath(), "/ping/{id}"); + assertNull(pingOperationPathAdapter.get().contextPath()); + assertEquals(pingOperationPathAdapter.get().fullPath(), "/ping/{id}"); + } + + @DataProvider(name = "lazyInitializationDataprovider") + public static Object[][] specSources() { + return new Object[][]{ + {null, "classpath:org/citrusframework/openapi/ping/ping-api.yaml"}, + {null, PING_API_HTTP_URL_STRING}, + {null, PING_API_HTTPS_URL_STRING}, + {null, "/ping-api.yaml"}, + {"http://org.citrus.sample", "/ping-api.yaml"} + }; + } + + @Test(dataProvider = "lazyInitializationDataprovider") + public void shouldDisableEnableRequestValidationWhenSet(String requestUrl, String specSource) throws IOException { + + // Given + OpenApiSpecification specification = new OpenApiSpecification() { + @Override + URL toSpecUrl(String resolvedSpecUrl) { + return mockUrlConnection(resolvedSpecUrl); + } + }; + specification.setRequestUrl(requestUrl); + specification.setHttpClient("sampleHttpClient"); + specification.setSpecUrl(specSource); + when(testContextMock.replaceDynamicContentInString(isA(String.class))).thenAnswer(answer-> + answer.getArgument(0) + ); + + when(testContextMock.getReferenceResolver()).thenReturn(referenceResolverMock); + when(referenceResolverMock.isResolvable("sampleHttpClient", HttpClient.class)).thenReturn(true); + when(referenceResolverMock.resolve("sampleHttpClient", HttpClient.class)).thenReturn(httpClient); + when(httpClient.getEndpointConfiguration()).thenReturn(endpointConfigurationMock); + when(endpointConfigurationMock.getRequestUrl()).thenReturn("http://org.citrus.sample"); + + boolean sampleHttpCient = testContextMock.getReferenceResolver() + .isResolvable("sampleHttpClient", HttpClient.class); + + // When + specification.setRequestValidationEnabled(false); + + // Then (not yet initialized) + assertFalse(specification.isRequestValidationEnabled()); + assertFalse(specification.getRequestValidator().isPresent()); + + // When (initialize) + specification.getOpenApiDoc(testContextMock); + + // Then + assertFalse(specification.isRequestValidationEnabled()); + assertTrue(specification.getRequestValidator().isPresent()); + assertTrue(specification.getRequestValidator().isPresent()); + + // When + specification.setRequestValidationEnabled(true); + + // Then + assertTrue(specification.isRequestValidationEnabled()); + assertTrue(specification.getRequestValidator().isPresent()); + assertTrue(specification.getRequestValidator().get().isEnabled()); + + } + + private static URL mockUrlConnection(String urlString) { + try { + HttpsURLConnection httpsURLConnectionMock = mock(); + when(httpsURLConnectionMock.getResponseCode()).thenReturn(200); + when(httpsURLConnectionMock.getInputStream()).thenAnswer( + invocation -> new ByteArrayInputStream(PING_API_STRING.getBytes( + StandardCharsets.UTF_8))); + + URL urlMock = mock(); + when(urlMock.getProtocol()).thenReturn(urlString.substring(0,urlString.indexOf(":"))); + when(urlMock.toString()).thenReturn(urlString); + when(urlMock.openConnection()).thenReturn(httpsURLConnectionMock); + return urlMock; + } catch (Exception e) { + throw new CitrusRuntimeException("Unable to mock spec url!", e); + } + } + + @Test + public void shouldDisableEnableResponseValidationWhenSet() { + // Given + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // When + specification.setResponseValidationEnabled(false); + + // Then + assertFalse(specification.isResponseValidationEnabled()); + assertTrue(specification.getResponseValidator().isPresent()); + assertFalse(specification.getResponseValidator().get().isEnabled()); + + // When + specification.setResponseValidationEnabled(true); + + // Then + assertTrue(specification.isResponseValidationEnabled()); + assertTrue(specification.getResponseValidator().isPresent()); + assertTrue(specification.getResponseValidator().get().isEnabled()); + + } + + @Test + public void shouldAddAlias() { + String alias = "alias1"; + openApiSpecification.addAlias(alias); + + assertTrue(openApiSpecification.getAliases().contains(alias)); + } + + @Test + public void shouldReturnSpecUrl() { + URL url = openApiSpecification.toSpecUrl(PING_API_HTTP_URL_STRING); + + assertNotNull(url); + + assertEquals(url.toString(), PING_API_HTTP_URL_STRING); + } + + @Test + public void shouldSetRootContextPathAndReinitialize() { + // Given/When + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // Then + assertNull(openApiSpecification.getRootContextPath()); + + assertPingApi(specification); + + // When + specification.setRootContextPath("/root"); + + Optional pingOperationPathAdapter = specification.getOperation( + PING_OPERATION_ID, + testContextMock); + assertTrue(pingOperationPathAdapter.isPresent()); + assertEquals(pingOperationPathAdapter.get().apiPath(), "/ping/{id}"); + assertEquals(pingOperationPathAdapter.get().contextPath(), "/root"); + assertEquals(pingOperationPathAdapter.get().fullPath(), "/root/ping/{id}"); + + Optional pongOperationPathAdapter = specification.getOperation( + PONG_OPERATION_ID, + testContextMock); + assertTrue(pongOperationPathAdapter.isPresent()); + assertEquals(pongOperationPathAdapter.get().apiPath(), "/pong/{id}"); + assertEquals(pongOperationPathAdapter.get().contextPath(), "/root"); + assertEquals(pongOperationPathAdapter.get().fullPath(), "/root/pong/{id}"); + + // Verify initPathLookups is called, which would require a spy + } + + @Test + public void shouldSeAndProvideProperties() { + + openApiSpecification.setValidateOptionalFields(true); + openApiSpecification.setGenerateOptionalFields(true); + + assertTrue(openApiSpecification.isValidateOptionalFields()); + assertTrue(openApiSpecification.isGenerateOptionalFields()); + + openApiSpecification.setValidateOptionalFields(false); + openApiSpecification.setGenerateOptionalFields(false); + + assertFalse(openApiSpecification.isValidateOptionalFields()); + assertFalse(openApiSpecification.isGenerateOptionalFields()); + + } + + @Test + public void shouldReturnSpecUrlInAbsenceOfRequestUrl() { + + openApiSpecification.setSpecUrl(PING_API_HTTP_URL_STRING); + + assertEquals(openApiSpecification.getSpecUrl(), PING_API_HTTP_URL_STRING); + assertEquals(openApiSpecification.getRequestUrl(), PING_API_HTTP_URL_STRING); + + openApiSpecification.setSpecUrl("/ping-api.yaml"); + openApiSpecification.setRequestUrl("http://or.citrus.sample"); + + assertEquals(openApiSpecification.getSpecUrl(), "/ping-api.yaml"); + assertEquals(openApiSpecification.getRequestUrl(), "http://or.citrus.sample"); + + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java new file mode 100644 index 0000000000..89d19ac670 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java @@ -0,0 +1,49 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.Map; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.spi.Resources; +import org.testng.Assert; +import org.testng.annotations.Test; + +// TODO: Add more tests +public class OpenApiTestDataGeneratorTest { + + private final OpenApiSpecification pingSpec = OpenApiSpecification.from( + Resources.create("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // TODO: fix this by introducing mature validation + @Test + public void failsToValidateAnyOf() throws JsonProcessingException { + + Map schemaDefinitions = OasModelHelper.getSchemaDefinitions( + pingSpec.getOpenApiDoc(null)); + assertNotNull(schemaDefinitions); + assertFalse(schemaDefinitions.isEmpty()); + Assert.assertEquals(schemaDefinitions.size(), 15); + + Assert.assertThrows(() -> OpenApiTestDataGenerator.createValidationExpression( + schemaDefinitions.get("PingRespType"), schemaDefinitions, true, pingSpec)); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java new file mode 100644 index 0000000000..2c411b1179 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java @@ -0,0 +1,80 @@ +package org.citrusframework.openapi; + +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +public class OpenApiUtilsTest { + + @Mock + private HttpMessage httpMessageMock; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldReturnFormattedMethodPathWhenHttpMessageHasMethodAndPath() { + // Given + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_METHOD)).thenReturn("GET"); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/path"); + + // When + String methodPath = OpenApiUtils.getMethodPath(httpMessageMock); + + // Then + assertEquals(methodPath, "/get/api/path"); + } + + @Test + public void shouldReturnDefaultMethodPathWhenHttpMessageHasNoMethodAndPath() { + // Given + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_METHOD)).thenReturn(null); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn(null); + + // When + String methodPath = OpenApiUtils.getMethodPath(httpMessageMock); + + // Then + assertEquals(methodPath, "/null/null"); + } + + @Test + public void shouldReturnFormattedMethodPathWhenMethodAndPathAreProvided() { + // When + String methodPath = OpenApiUtils.getMethodPath("POST", "/api/path"); + // Then + assertEquals(methodPath, "/post/api/path"); + } + + @Test + public void shouldReturnFormattedMethodPathWhenMethodIsEmptyAndPathIsProvided() { + // When + String methodPath = OpenApiUtils.getMethodPath("", "/api/path"); + // Then + assertEquals(methodPath, "//api/path"); + } + + @Test + public void shouldReturnFormattedMethodPathWhenMethodAndPathAreEmpty() { + // When + String methodPath = OpenApiUtils.getMethodPath("", ""); + // Then + assertEquals(methodPath, "//"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java index c5326f1259..3bd7e60ec1 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java @@ -47,6 +47,9 @@ import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; /** * @author Christoph Deppisch @@ -114,89 +117,90 @@ public void shouldLoadOpenApiServerActions() { testLoader.load(); TestCase result = testLoader.getTestCase(); - Assert.assertEquals(result.getName(), "OpenApiServerTest"); - Assert.assertEquals(result.getMetaInfo().getAuthor(), "Christoph"); - Assert.assertEquals(result.getMetaInfo().getStatus(), TestCaseMetaInfo.Status.FINAL); - Assert.assertEquals(result.getActionCount(), 4L); - Assert.assertEquals(result.getTestAction(0).getClass(), ReceiveMessageAction.class); - Assert.assertEquals(result.getTestAction(0).getName(), "openapi:receive-request"); + assertEquals(result.getName(), "OpenApiServerTest"); + assertEquals(result.getMetaInfo().getAuthor(), "Christoph"); + assertEquals(result.getMetaInfo().getStatus(), TestCaseMetaInfo.Status.FINAL); + assertEquals(result.getActionCount(), 4L); + assertEquals(result.getTestAction(0).getClass(), ReceiveMessageAction.class); + assertEquals(result.getTestAction(0).getName(), "openapi:receive-request"); - Assert.assertEquals(result.getTestAction(1).getClass(), SendMessageAction.class); - Assert.assertEquals(result.getTestAction(1).getName(), "openapi:send-response"); + assertEquals(result.getTestAction(1).getClass(), SendMessageAction.class); + assertEquals(result.getTestAction(1).getName(), "openapi:send-response"); int actionIndex = 0; ReceiveMessageAction receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); - Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); - Assert.assertEquals(receiveMessageAction.getReceiveTimeout(), 0L); + assertEquals(receiveMessageAction.getValidationContexts().size(), 3); + assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); + assertEquals(receiveMessageAction.getReceiveTimeout(), 0L); - Assert.assertTrue(receiveMessageAction.getMessageBuilder() instanceof HttpMessageBuilder); + assertTrue(receiveMessageAction.getMessageBuilder() instanceof HttpMessageBuilder); HttpMessageBuilder httpMessageBuilder = ((HttpMessageBuilder)receiveMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - Assert.assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), ""); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.GET.name()); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet/${petId}"); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet/${petId}"); + assertNotNull(httpMessageBuilder); + assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), ""); + assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.GET.name()); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet/${petId}"); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet/${petId}"); Assert.assertNull(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_QUERY_PARAMS)); Assert.assertNull(httpMessageBuilder.getMessage().getHeaders().get(EndpointUriResolver.ENDPOINT_URI_HEADER_NAME)); - Assert.assertEquals(receiveMessageAction.getEndpoint(), httpServer); - Assert.assertEquals(receiveMessageAction.getControlMessageProcessors().size(), 0); + assertEquals(receiveMessageAction.getEndpoint(), httpServer); + assertEquals(receiveMessageAction.getControlMessageProcessors().size(), 0); SendMessageAction sendMessageAction = (SendMessageAction) result.getTestAction(actionIndex++); httpMessageBuilder = ((HttpMessageBuilder)sendMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - - Assert.assertTrue(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()).toString().startsWith("{\"id\": ")); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); + assertNotNull(httpMessageBuilder); + + assertTrue(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()).toString().startsWith("{\"id\": ")); + assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "application/json"); + Assert.assertNull(sendMessageAction.getEndpoint()); - Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); - Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 0); + assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); + assertEquals(sendMessageAction.getMessageProcessors().size(), 1); receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); - Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); - Assert.assertEquals(receiveMessageAction.getReceiveTimeout(), 2000L); + assertEquals(receiveMessageAction.getValidationContexts().size(), 3); + assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); + assertEquals(receiveMessageAction.getReceiveTimeout(), 2000L); httpMessageBuilder = ((HttpMessageBuilder)receiveMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - Assert.assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), + assertNotNull(httpMessageBuilder); + assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), "{\"id\": \"@isNumber()@\",\"category\": {\"id\": \"@isNumber()@\",\"name\": \"@notEmpty()@\"},\"name\": \"@notEmpty()@\",\"photoUrls\": \"@ignore@\",\"tags\": \"@ignore@\",\"status\": \"@matches(available|pending|sold)@\"}"); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); Map requestHeaders = httpMessageBuilder.buildMessageHeaders(context); - Assert.assertEquals(requestHeaders.size(), 4L); - Assert.assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.POST.name()); - Assert.assertEquals(requestHeaders.get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet"); - Assert.assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet"); - Assert.assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "@startsWith(application/json)@"); + assertEquals(requestHeaders.size(), 4L); + assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.POST.name()); + assertEquals(requestHeaders.get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet"); + assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet"); + assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "@startsWith(application/json)@"); Assert.assertNull(receiveMessageAction.getEndpointUri()); - Assert.assertEquals(receiveMessageAction.getEndpoint(), httpServer); + assertEquals(receiveMessageAction.getEndpoint(), httpServer); sendMessageAction = (SendMessageAction) result.getTestAction(actionIndex); httpMessageBuilder = ((HttpMessageBuilder)sendMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - Assert.assertEquals(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()), ""); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertNotNull(httpMessageBuilder); + assertEquals(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()), ""); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); Map responseHeaders = httpMessageBuilder.buildMessageHeaders(context); - Assert.assertEquals(responseHeaders.size(), 2L); - Assert.assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_STATUS_CODE), 201); - Assert.assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_REASON_PHRASE), "CREATED"); + assertEquals(responseHeaders.size(), 2L); + assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_STATUS_CODE), 201); + assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_REASON_PHRASE), "CREATED"); Assert.assertNull(sendMessageAction.getEndpoint()); - Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); + assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java index ffa4e9dcdf..7e66c3f0bf 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java @@ -16,12 +16,21 @@ package org.citrusframework.openapi.integration; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + import org.citrusframework.annotations.CitrusTest; +import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.http.actions.HttpClientRequestActionBuilder.HttpMessageBuilderSupport; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpClientBuilder; import org.citrusframework.http.server.HttpServer; import org.citrusframework.http.server.HttpServerBuilder; import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiClientResponseActionBuilder; import org.citrusframework.spi.BindToRegistry; import org.citrusframework.spi.Resources; import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; @@ -29,15 +38,14 @@ import org.springframework.http.HttpStatus; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; - /** * @author Christoph Deppisch */ -@Test public class OpenApiClientIT extends TestNGCitrusSpringSupport { + public static final String VALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet.json"; + public static final String INVALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet_invalid.json"; + private final int port = SocketUtils.findAvailableTcpPort(8080); @BindToRegistry @@ -53,14 +61,23 @@ public class OpenApiClientIT extends TestNGCitrusSpringSupport { .requestUrl("http://localhost:%d".formatted(port)) .build(); + /** + * Directly loaded open api. + */ private final OpenApiSpecification petstoreSpec = OpenApiSpecification.from( Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); @CitrusTest - public void getPetById() { + @Test + public void shouldExecuteGetPetByIdFromDirectSpec() { + shouldExecuteGetPetById(openapi(petstoreSpec), VALID_PET_PATH, true); + } + + private void shouldExecuteGetPetById(OpenApiActionBuilder openapi, String responseFile, boolean valid) { + variable("petId", "1001"); - when(openapi(petstoreSpec) + when(openapi .client(httpClient) .send("getPetById") .fork(true)); @@ -75,19 +92,87 @@ public void getPetById() { .send() .response(HttpStatus.OK) .message() - .body(Resources.create("classpath:org/citrusframework/openapi/petstore/pet.json")) + .body(Resources.create(responseFile)) .contentType("application/json")); - then(openapi(petstoreSpec) - .client(httpClient) - .receive("getPetById", HttpStatus.OK)); + OpenApiClientResponseActionBuilder clientResponseActionBuilder = openapi + .client(httpClient).receive("getPetById", HttpStatus.OK); + if (valid) { + then(clientResponseActionBuilder); + } else { + assertThrows(() -> then(clientResponseActionBuilder)); + } + } + + @CitrusTest + @Test + public void shouldProperlyExecuteGetAndAddPetFromDirectSpec() { + shouldExecuteGetAndAddPet(openapi(petstoreSpec)); + } + + @CitrusTest + @Test + public void shouldProperlyExecuteGetAndAddPetFromRepository() { + shouldExecuteGetAndAddPet(openapi(petstoreSpec)); } @CitrusTest - public void getAddPet() { + @Test + public void shouldFailOnMissingNameInResponse() { + shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, false); + } + + @CitrusTest + @Test + public void shouldFailOnMissingNameInRequest() { + variable("petId", "1001"); + + HttpMessageBuilderSupport addPetBuilder = openapi(petstoreSpec) + .client(httpClient) + .send("addPet") + .message().body(Resources.create(INVALID_PET_PATH)) + .fork(true); + + assertThrows(TestCaseFailedException.class, () ->when(addPetBuilder)); + } + + @CitrusTest + @Test + public void shouldFailOnWrongQueryIdType() { + variable("petId", "xxxx"); + HttpMessageBuilderSupport addPetBuilder = openapi(petstoreSpec) + .client(httpClient) + .send("addPet") + .message().body(Resources.create(VALID_PET_PATH)) + .fork(true); + + assertThrows(TestCaseFailedException.class, () ->when(addPetBuilder)); + } + + @CitrusTest + @Test + public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { + variable("petId", "xxxx"); + HttpMessageBuilderSupport addPetBuilder = openapi(petstoreSpec) + .client(httpClient) + .send("addPet") + .disableOasValidation(true) + .message().body(Resources.create(VALID_PET_PATH)) + .fork(true); + + try { + when(addPetBuilder); + } catch (Exception e) { + fail("Method threw an exception: " + e.getMessage()); + } + + } + + private void shouldExecuteGetAndAddPet(OpenApiActionBuilder openapi) { + variable("petId", "1001"); - when(openapi(petstoreSpec) + when(openapi .client(httpClient) .send("addPet") .fork(true)); @@ -116,7 +201,7 @@ public void getAddPet() { .response(HttpStatus.CREATED) .message()); - then(openapi(petstoreSpec) + then(openapi .client(httpClient) .receive("addPet", HttpStatus.CREATED)); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java index 216445a5d3..c98af2553f 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java @@ -16,28 +16,42 @@ package org.citrusframework.openapi.integration; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + +import java.util.List; import org.citrusframework.annotations.CitrusTest; +import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder.HttpMessageBuilderSupport; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpClientBuilder; import org.citrusframework.http.server.HttpServer; import org.citrusframework.http.server.HttpServerBuilder; +import org.citrusframework.openapi.OpenApiRepository; import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerResponseActionBuilder; import org.citrusframework.spi.BindToRegistry; import org.citrusframework.spi.Resources; import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; import org.citrusframework.util.SocketUtils; import org.springframework.http.HttpStatus; +import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; - /** * @author Christoph Deppisch */ @Test public class OpenApiServerIT extends TestNGCitrusSpringSupport { + public static final String VALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet.json"; + public static final String INVALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet_invalid.json"; + private final int port = SocketUtils.findAvailableTcpPort(8080); @BindToRegistry @@ -53,11 +67,19 @@ public class OpenApiServerIT extends TestNGCitrusSpringSupport { .requestUrl("http://localhost:%d/petstore/v3".formatted(port)) .build(); + /** + * Directly loaded open api. + */ private final OpenApiSpecification petstoreSpec = OpenApiSpecification.from( - Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); + Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); @CitrusTest - public void getPetById() { + public void shouldExecuteGetPetById() { + shouldExecuteGetPetById(openapi(petstoreSpec)); + } + + + private void shouldExecuteGetPetById(OpenApiActionBuilder openapi) { variable("petId", "1001"); when(http() @@ -68,11 +90,11 @@ public void getPetById() { .accept("application/json") .fork(true)); - then(openapi(petstoreSpec) + then(openapi .server(httpServer) .receive("getPetById")); - then(openapi(petstoreSpec) + then(openapi .server(httpServer) .send("getPetById", HttpStatus.OK)); @@ -97,7 +119,86 @@ public void getPetById() { } @CitrusTest - public void getAddPet() { + public void shouldExecuteAddPet() { + shouldExecuteAddPet(openapi(petstoreSpec), VALID_PET_PATH, true); + } + + @CitrusTest + public void shouldFailOnMissingNameInRequest() { + shouldExecuteAddPet(openapi(petstoreSpec), INVALID_PET_PATH, false); + } + + @CitrusTest + public void shouldFailOnMissingNameInResponse() { + variable("petId", "1001"); + + when(http() + .client(httpClient) + .send() + .get("/pet/${petId}") + .message() + .accept("application/json") + .fork(true)); + + then(openapi(petstoreSpec) + .server(httpServer) + .receive("getPetById")); + + OpenApiServerResponseActionBuilder sendMessageActionBuilder = openapi(petstoreSpec) + .server(httpServer) + .send("getPetById", HttpStatus.OK); + sendMessageActionBuilder.message().body(Resources.create(INVALID_PET_PATH)); + + assertThrows(TestCaseFailedException.class, () -> then(sendMessageActionBuilder)); + + } + + @CitrusTest + public void shouldFailOnWrongQueryIdTypeWithOasDisabled() { + variable("petId", "xxx"); + + when(http() + .client(httpClient) + .send() + .post("/pet") + .message() + .body(Resources.create(VALID_PET_PATH)) + .contentType("application/json") + .fork(true)); + + OpenApiServerRequestActionBuilder addPetBuilder = openapi(petstoreSpec) + .server(httpServer) + .receive("addPet"); + + assertThrows(TestCaseFailedException.class, () -> then(addPetBuilder)); + } + + @CitrusTest + public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { + variable("petId", "xxx"); + + when(http() + .client(httpClient) + .send() + .post("/pet") + .message() + .body(Resources.create(VALID_PET_PATH)) + .contentType("application/json") + .fork(true)); + + OpenApiServerRequestActionBuilder addPetBuilder = openapi(petstoreSpec) + .server(httpServer) + .receive("addPet") + .disableOasValidation(true); + + try { + when(addPetBuilder); + } catch (Exception e) { + fail("Method threw an exception: " + e.getMessage()); + } + } + + private void shouldExecuteAddPet(OpenApiActionBuilder openapi, String requestFile, boolean valid) { variable("petId", "1001"); when(http() @@ -105,15 +206,20 @@ public void getAddPet() { .send() .post("/pet") .message() - .body(Resources.create("classpath:org/citrusframework/openapi/petstore/pet.json")) + .body(Resources.create(requestFile)) .contentType("application/json") .fork(true)); - then(openapi(petstoreSpec) - .server(httpServer) - .receive("addPet")); + OpenApiServerRequestActionBuilder receiveActionBuilder = openapi + .server(httpServer) + .receive("addPet"); + if (valid) { + then(receiveActionBuilder); + } else { + assertThrows(() -> then(receiveActionBuilder)); + } - then(openapi(petstoreSpec) + then(openapi .server(httpServer) .send("addPet", HttpStatus.CREATED)); diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java new file mode 100644 index 0000000000..cec2e46744 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java @@ -0,0 +1,34 @@ +package org.citrusframework.openapi.model; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import org.citrusframework.openapi.OpenApiUtils; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static java.lang.String.format; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +public class OperationPathAdapterTest { + + + @Test + public void shouldReturnFormattedStringWhenToStringIsCalled() { + // Given + Oas30Operation oas30Operation = new Oas30Operation("get"); + oas30Operation.operationId = "operationId"; + + OperationPathAdapter adapter = new OperationPathAdapter("/api/path", "/context/path", "/full/path", oas30Operation); + + // When + String expectedString = format("%s (%s)", OpenApiUtils.getMethodPath("GET", "/api/path"), "operationId"); + + // Then + assertEquals(adapter.toString(), expectedString); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java index 742641ad6c..f57d3c46ce 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java @@ -14,12 +14,13 @@ import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; import java.util.List; import java.util.Optional; +import org.citrusframework.openapi.model.OasModelHelper; import org.testng.annotations.Test; public class Oas20ModelHelperTest { @Test - public void shouldFindRandomResponse() { + public void shouldFindRandomResponseWithGoodStatusCode() { Oas20Document document = new Oas20Document(); Oas20Operation operation = new Oas20Operation("GET"); @@ -35,29 +36,55 @@ public void shouldFindRandomResponse() { operation.responses.addResponse("403", nokResponse); operation.responses.addResponse("200", okResponse); - Optional responseForRandomGeneration = Oas20ModelHelper.getResponseForRandomGeneration( - document, operation); + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); assertTrue(responseForRandomGeneration.isPresent()); assertEquals(okResponse, responseForRandomGeneration.get()); } @Test - public void shouldNotFindAnyResponse() { + public void shouldFindFirstResponseInAbsenceOfAGoodOne() { Oas20Document document = new Oas20Document(); Oas20Operation operation = new Oas20Operation("GET"); operation.responses = new Oas20Responses(); Oas20Response nokResponse403 = new Oas20Response("403"); + nokResponse403.schema = new Oas20Schema(); Oas20Response nokResponse407 = new Oas20Response("407"); + nokResponse407.schema = new Oas20Schema(); operation.responses = new Oas20Responses(); operation.responses.addResponse("403", nokResponse403); operation.responses.addResponse("407", nokResponse407); - Optional responseForRandomGeneration = Oas20ModelHelper.getResponseForRandomGeneration( - document, operation); - assertTrue(responseForRandomGeneration.isEmpty()); + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "403"); + } + + @Test + public void shouldFindDefaultResponseInAbsenceOfAGoodOne() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse403 = new Oas20Response("403"); + nokResponse403.schema = new Oas20Schema(); + Oas20Response nokResponse407 = new Oas20Response("407"); + nokResponse407.schema = new Oas20Schema(); + + operation.responses = new Oas20Responses(); + operation.responses.default_ = nokResponse407; + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "407"); } @Test diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java index 8735ecd083..9feee74a1a 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.Map; import java.util.Optional; +import org.citrusframework.openapi.model.OasModelHelper; import org.springframework.http.MediaType; import org.testng.annotations.Test; @@ -26,7 +27,7 @@ public class Oas30ModelHelperTest { public void shouldNotFindRequiredHeadersWithoutRequiredAttribute() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = null; // explicitly assigned because this is test case + header.required = null; var response = new Oas30Response("200"); response.headers.put(header.getName(), header); @@ -39,7 +40,7 @@ public void shouldNotFindRequiredHeadersWithoutRequiredAttribute() { public void shouldFindRequiredHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.TRUE; // explicitly assigned because this is test case + header.required = Boolean.TRUE; var response = new Oas30Response("200"); response.headers.put(header.getName(), header); @@ -53,7 +54,7 @@ public void shouldFindRequiredHeaders() { public void shouldNotFindOptionalHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.FALSE; // explicitly assigned because this is test case + header.required = Boolean.FALSE; var response = new Oas30Response("200"); response.headers.put(header.getName(), header); @@ -83,7 +84,7 @@ public void shouldFindAllRequestTypesForOperation() { } @Test - public void shouldFindRandomResponse() { + public void shouldFindRandomResponseWithGoodStatusCode() { Oas30Document document = new Oas30Document(); Oas30Operation operation = new Oas30Operation("GET"); @@ -108,14 +109,14 @@ public void shouldFindRandomResponse() { operation.responses.addResponse("403", nokResponse); operation.responses.addResponse("200", okResponse); - Optional responseForRandomGeneration = Oas30ModelHelper.getResponseForRandomGeneration( - document, operation); + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); assertTrue(responseForRandomGeneration.isPresent()); assertEquals(okResponse, responseForRandomGeneration.get()); } @Test - public void shouldFindAnyResponse() { + public void shouldFindFirstResponseInAbsenceOfAGoodOne() { Oas30Document document = new Oas30Document(); Oas30Operation operation = new Oas30Operation("GET"); @@ -133,10 +134,37 @@ public void shouldFindAnyResponse() { operation.responses.addResponse("403", nokResponse403); operation.responses.addResponse("407", nokResponse407); - Optional responseForRandomGeneration = Oas30ModelHelper.getResponseForRandomGeneration( - document, operation); + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); assertTrue(responseForRandomGeneration.isPresent()); - assertEquals(nokResponse403, responseForRandomGeneration.get()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "403"); + + } + + @Test + public void shouldFindDefaultResponseInAbsenceOfAGoodOne() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse403 = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse403.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response nokResponse407 = new Oas30Response("407"); + nokResponse407.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.default_ = nokResponse407; + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "407"); } @Test diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java new file mode 100644 index 0000000000..fc72cd5ade --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java @@ -0,0 +1,118 @@ +package org.citrusframework.openapi.validation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OpenApiRequestValidationProcessorTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private OpenApiRequestValidator requestValidatorMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + @InjectMocks + private OpenApiRequestValidationProcessor processor; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + processor = new OpenApiRequestValidationProcessor(openApiSpecificationMock, "operationId"); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateWhenDisabled() { + processor.setEnabled(false); + HttpMessage messageMock = mock(); + + processor.validate(messageMock, mock()); + + verify(openApiSpecificationMock, never()).getOperation(any(), any()); + } + + @Test + public void shouldNotValidateNonHttpMessage() { + Message messageMock = mock(); + + processor.validate(messageMock, mock()); + + verify(openApiSpecificationMock, never()).getOperation(any(), any()); + } + + @Test + public void shouldValidateHttpMessage() { + processor.setEnabled(true); + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.of(operationPathAdapterMock)); + when(openApiSpecificationMock.getRequestValidator()) + .thenReturn(Optional.of(requestValidatorMock)); + + processor.validate(httpMessageMock, contextMock); + + verify(requestValidatorMock, times(1)).validateRequest(operationPathAdapterMock, httpMessageMock); + } + + @Test + public void shouldNotValidateWhenNoOperation() { + processor.setEnabled(true); + HttpMessage httpMessage = mock(HttpMessage.class); + TestContext context = mock(TestContext.class); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.empty()); + + processor.validate(httpMessage, context); + + verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); + verify(openApiSpecificationMock, never()).getRequestValidator(); + } + + @Test + public void shouldNotValidateWhenNoValidator() { + processor.setEnabled(true); + HttpMessage httpMessage = mock(HttpMessage.class); + TestContext context = mock(TestContext.class); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.of(operationPathAdapterMock)); + when(openApiSpecificationMock.getRequestValidator()) + .thenReturn(Optional.empty()); + + processor.validate(httpMessage, context); + + verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); + verify(openApiSpecificationMock, times(1)).getRequestValidator(); + verify(requestValidatorMock, never()).validateRequest(any(), any()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java new file mode 100644 index 0000000000..d1decc05a3 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java @@ -0,0 +1,148 @@ +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.Request.Method; +import com.atlassian.oai.validator.report.ValidationReport; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.validation.OpenApiRequestValidator; +import org.mockito.*; +import org.springframework.web.bind.annotation.RequestMethod; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +public class OpenApiRequestValidatorTest { + + @Mock + private OpenApiInteractionValidator openApiInteractionValidatorMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + @Mock + private HttpMessage httpMessageMock; + + @Mock + private ValidationReport validationReportMock; + + @InjectMocks + private OpenApiRequestValidator openApiRequestValidator; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + openApiRequestValidator = new OpenApiRequestValidator(openApiInteractionValidatorMock); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateWhenDisabled() { + // Given + openApiRequestValidator.setEnabled(false); + // When + openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); + // Then + Assert.assertFalse(openApiRequestValidator.isEnabled()); + verify(openApiInteractionValidatorMock, never()).validateRequest(any(Request.class)); + } + + @Test + public void shouldValidateRequestWithNoErrors() { + // Given + openApiRequestValidator.setEnabled(true); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/test"); + when(httpMessageMock.getRequestMethod()).thenReturn(RequestMethod.GET); + when(openApiInteractionValidatorMock.validateRequest(any(Request.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(false); + + // When + openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock, times(1)).validateRequest(any(Request.class)); + verify(validationReportMock, times(1)).hasErrors(); + } + + @Test(expectedExceptions = ValidationException.class) + public void shouldValidateRequestWithErrors() { + // Given + openApiRequestValidator.setEnabled(true); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/test"); + when(httpMessageMock.getRequestMethod()).thenReturn(RequestMethod.GET); + when(openApiInteractionValidatorMock.validateRequest(any(Request.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(true); + + // When + openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock, times(1)).validateRequest(any(Request.class)); + verify(validationReportMock, times(1)).hasErrors(); + } + + @Test + public void shouldCreateRequestFromMessage() throws IOException { + // Given + when(httpMessageMock.getPayload()).thenReturn("payload"); + + Map headers = new HashMap<>(); + headers.put("array", List.of("e1", "e2")); + headers.put("nullarray", null); + headers.put("simple", "s1"); + + when(httpMessageMock.getHeaders()).thenReturn(headers); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/test"); + when(httpMessageMock.getRequestMethod()).thenReturn(RequestMethod.GET); + when(httpMessageMock.getAccept()).thenReturn("application/json"); + when(operationPathAdapterMock.contextPath()).thenReturn("/api"); + + // When + Request request = openApiRequestValidator.createRequestFromMessage(operationPathAdapterMock, httpMessageMock); + + // Then + assertNotNull(request); + assertEquals(request.getPath(), "/test"); + assertEquals(request.getMethod(), Method.GET); + assertEquals(request.getHeaders().get("array"), List.of("e1", "e2")); + assertEquals(request.getHeaders().get("simple"), List.of("s1")); + List nullList = new ArrayList<>(); + nullList.add(null); + assertEquals(request.getHeaders().get("nullarray"), nullList); + assertTrue(request.getRequestBody().isPresent()); + + assertEquals(request.getRequestBody().get().toString(StandardCharsets.UTF_8), "payload"); + } + + private Request callCreateRequestFromMessage(OpenApiRequestValidator validator, OperationPathAdapter adapter, HttpMessage message) { + try { + var method = OpenApiRequestValidator.class.getDeclaredMethod("createRequestFromMessage", OperationPathAdapter.class, HttpMessage.class); + method.setAccessible(true); + return (Request) method.invoke(validator, adapter, message); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java new file mode 100644 index 0000000000..a7aabba892 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java @@ -0,0 +1,114 @@ +package org.citrusframework.openapi.validation; + +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class OpenApiResponseValidationProcessorTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private OpenApiResponseValidator responseValidatorMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + @InjectMocks + private OpenApiResponseValidationProcessor processor; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + processor = new OpenApiResponseValidationProcessor(openApiSpecificationMock, "operationId"); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateWhenDisabled() { + processor.setEnabled(false); + HttpMessage messageMock = mock(); + + processor.validate(messageMock, mock()); + + verify(openApiSpecificationMock, never()).getOperation(any(), any()); + } + + @Test + public void shouldNotValidateNonHttpMessage() { + Message messageMock = mock(); + + processor.validate(messageMock, mock()); + + verify(openApiSpecificationMock, never()).getOperation(any(), any()); + } + + @Test + public void shouldValidateHttpMessage() { + processor.setEnabled(true); + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.of(operationPathAdapterMock)); + when(openApiSpecificationMock.getResponseValidator()) + .thenReturn(Optional.of(responseValidatorMock)); + + processor.validate(httpMessageMock, contextMock); + + verify(responseValidatorMock, times(1)).validateResponse(operationPathAdapterMock, httpMessageMock); + } + + @Test + public void shouldNotValidateWhenNoOperation() { + processor.setEnabled(true); + HttpMessage httpMessage = mock(HttpMessage.class); + TestContext context = mock(TestContext.class); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.empty()); + + processor.validate(httpMessage, context); + + verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); + verify(openApiSpecificationMock, never()).getResponseValidator(); + } + + @Test + public void shouldNotValidateWhenNoValidator() { + processor.setEnabled(true); + HttpMessage httpMessage = mock(HttpMessage.class); + TestContext context = mock(TestContext.class); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.of(operationPathAdapterMock)); + when(openApiSpecificationMock.getResponseValidator()) + .thenReturn(Optional.empty()); + + processor.validate(httpMessage, context); + + verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); + verify(openApiSpecificationMock, times(1)).getResponseValidator(); + verify(responseValidatorMock, never()).validateResponse(any(), any()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java new file mode 100644 index 0000000000..d8d4433447 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java @@ -0,0 +1,128 @@ +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request.Method; +import com.atlassian.oai.validator.model.Response; +import com.atlassian.oai.validator.report.ValidationReport; +import io.apicurio.datamodels.core.models.common.Operation; +import io.apicurio.datamodels.openapi.models.OasOperation; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.*; +import org.springframework.http.HttpStatusCode; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +public class OpenApiResponseValidatorTest { + + @Mock + private OpenApiInteractionValidator openApiInteractionValidatorMock; + + @Mock + private OasOperation operationMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + @Mock + private HttpMessage httpMessageMock; + + @Mock + private ValidationReport validationReportMock; + + @InjectMocks + private OpenApiResponseValidator openApiResponseValidator; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + openApiResponseValidator = new OpenApiResponseValidator(openApiInteractionValidatorMock); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateWhenDisabled() { + // Given + openApiResponseValidator.setEnabled(false); + // When + openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); + // Then + Assert.assertFalse(openApiResponseValidator.isEnabled()); + verify(openApiInteractionValidatorMock, never()).validateResponse(anyString(), any(Method.class), any(Response.class)); + } + + @Test + public void shouldValidateWithNoErrors() { + // Given + openApiResponseValidator.setEnabled(true); + when(openApiInteractionValidatorMock.validateResponse(anyString(), any(Method.class), any(Response.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(false); + + when(operationPathAdapterMock.operation()).thenReturn(operationMock); + when(operationPathAdapterMock.apiPath()).thenReturn("/api/path"); + when(operationMock.getMethod()).thenReturn("get"); + when(httpMessageMock.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // When + openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock, times(1)).validateResponse(anyString(), any(Method.class), any(Response.class)); + verify(validationReportMock, times(1)).hasErrors(); + } + + @Test(expectedExceptions = ValidationException.class) + public void shouldValidateWithErrors() { + // Given + openApiResponseValidator.setEnabled(true); + when(openApiInteractionValidatorMock.validateResponse(anyString(), any(Method.class), any(Response.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(true); + + when(operationPathAdapterMock.operation()).thenReturn(operationMock); + when(operationPathAdapterMock.apiPath()).thenReturn("/api/path"); + when(operationMock.getMethod()).thenReturn("get"); + when(httpMessageMock.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // When + openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock, times(1)).validateResponse(anyString(), any(Method.class), any(Response.class)); + verify(validationReportMock, times(1)).hasErrors(); + } + + @Test + public void shouldCreateResponseMessage() throws IOException { + // Given + when(httpMessageMock.getPayload()).thenReturn("payload"); + when(httpMessageMock.getHeaders()).thenReturn(Map.of("Content-Type", "application/json")); + when(httpMessageMock.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // When + Response response = openApiResponseValidator.createResponseFromMessage(httpMessageMock, 200); + + // Then + assertNotNull(response); + assertEquals(response.getResponseBody().get().toString(StandardCharsets.UTF_8), "payload"); + assertEquals(response.getHeaderValue("Content-Type").get(), "application/json"); + assertEquals(response.getStatus(), Integer.valueOf(200)); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java index dcb704ebf6..0a14861377 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java @@ -127,7 +127,9 @@ public void shouldLoadOpenApiClientActions() throws IOException { context.getReferenceResolver().bind("httpClient", httpClient); context.getReferenceResolver().bind("httpServer", httpServer); - responses.add(new HttpMessage(FileUtils.readToString(Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.yaml")))); + String apiAsString = FileUtils.readToString(Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.yaml")); + responses.add(new HttpMessage(apiAsString)); + responses.add(new HttpMessage(apiAsString)); responses.add(new HttpMessage(""" { "id": 1000, diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java index b90685f1e4..4d0fcb7f0f 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java @@ -159,9 +159,10 @@ public void shouldLoadOpenApiServerActions() { Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "application/json"); + Assert.assertNull(sendMessageAction.getEndpoint()); Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); - Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 0); + Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 1); receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java index 02291ad91d..88ee6283c2 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java @@ -159,9 +159,10 @@ public void shouldLoadOpenApiServerActions() { Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "application/json"); + Assert.assertNull(sendMessageAction.getEndpoint()); Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); - Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 0); + Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 1); receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json new file mode 100644 index 0000000000..c265dff5be --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json @@ -0,0 +1,15 @@ +{ + "id": ${petId}, + "category": { + "id": ${petId}, + "name": "citrus:randomEnumValue('dog', 'cat', 'fish')" + }, + "photoUrls": [ "http://localhost:8080/photos/${petId}" ], + "tags": [ + { + "id": ${petId}, + "name": "generated" + } + ], + "status": "citrus:randomEnumValue('available', 'pending', 'sold')" +} diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml new file mode 100644 index 0000000000..f5e8f95b1a --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml @@ -0,0 +1,244 @@ +openapi: 3.0.1 +info: + title: Ping API + description: 'A simple OpenApi defining schemas for testing purposes' + version: 1.0 + +servers: + - url: http://localhost:9000/services/rest/ping/v1 + - url: http://localhost:9000/ping/v1 + +paths: + /ping/{id}: + put: + tags: + - ping + summary: Do the ping + operationId: doPing + parameters: + - name: id + in: path + description: Id to ping + required: true + schema: + type: integer + format: int64 + - name: q1 + in: query + description: Some queryParameter + required: true + schema: + type: integer + format: int64 + - name: api-key + in: header + description: Some header + required: true + schema: + type: string + requestBody: + description: Ping data + content: + application/json: + schema: + $ref: '#/components/schemas/PingReqType' + required: true + responses: + 200: + description: successful operation + headers: + ping-time: + required: false + description: response time + schema: + type: integer + format: int64 + content: + application/json: + schema: + $ref: '#/components/schemas/PingRespType' + plain/text: + schema: + type: string + 405: + description: Some error + content: + text/plain: + schema: + type: string + /pong/{id}: + get: + tags: + - pong + summary: Do the pong + operationId: doPong + parameters: + - name: id + in: path + description: Id to pong + required: true + explode: true + schema: + type: integer + format: int64 + responses: + 200: + description: successful operation without a response +components: + schemas: + Ipv6Type: + required: + - ipv6 + type: object + properties: + ipv6: + type: string + format: ipv6 + Ipv4Type: + required: + - ipv4 + type: object + properties: + ipv4: + type: string + format: ipv4 + DateType: + required: + - date + type: object + properties: + date: + type: string + format: date + DateTimeType: + required: + - dateTime + type: object + properties: + dateTime: + type: string + format: date-time + EmailType: + required: + - email + type: object + properties: + email: + type: string + format: email + ByteType: + required: + - byte + type: object + properties: + byte: + type: string + format: byte + BinaryType: + required: + - binary + type: object + properties: + binary: + type: string + format: binary + UriType: + required: + - uri + type: object + properties: + uri: + type: string + format: uri + UriReferenceType: + required: + - uriReference + type: object + properties: + uriReference: + type: string + format: uri-refence + HostnameType: + required: + - hostname + type: object + properties: + hostname: + type: string + format: hostname + AllTypes: + required: + - email + - ipv6 + - ipv4 + - date + - dateTime + - binary + - byte + - uri + - uriReference + - hostname + type: object + properties: + ipv6: + type: string + format: ipv6 + ipv4: + type: string + format: ipv4 + date: + type: string + format: date + dateTime: + type: string + format: date-time + email: + type: string + format: email + binary: + type: string + format: binary + byte: + type: string + format: byte + uri: + type: string + format: uri + uriReference: + type: string + format: uri-reference + hostname: + type: string + format: hostname + PingReqType: + type: object + properties: + id: + type: integer + format: int64 + Detail1: + type: object + properties: + host: + $ref: '#/components/schemas/HostnameType' + uri: + $ref: '#/components/schemas/UriType' + Detail2: + type: object + properties: + ipv4: + $ref: '#/components/schemas/Ipv4Type' + uriReference: + $ref: '#/components/schemas/UriReferenceType' + PingRespType: + type: object + properties: + id: + type: integer + format: int64 + value: + type: string + other: + anyOf: + - $ref: '#/components/schemas/Detail1' + - $ref: '#/components/schemas/Detail2' diff --git a/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java b/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java index b74b5355ca..c03f2b3263 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java +++ b/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java @@ -68,6 +68,7 @@ public DefaultMessage() { * @param message */ public DefaultMessage(Message message) { + this(message.getPayload(), message.getHeaders()); this.setName(message.getName()); diff --git a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java index d99932ea55..b740f82e27 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java +++ b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java @@ -21,6 +21,8 @@ */ public class StringUtils { + public static final String URL_PATH_SEPARATOR = "/"; + private StringUtils() { //prevent instantiation of utility class } @@ -43,7 +45,7 @@ public static boolean isEmpty(String str) { return str == null || str.isEmpty(); } - public static String appendSegmentToPath(String path, String segment) { + public static String appendSegmentToUrlPath(String path, String segment) { if (path == null) { return segment; @@ -53,14 +55,14 @@ public static String appendSegmentToPath(String path, String segment) { return path; } - if (!path.endsWith("/")) { - path = path +"/"; + if (!path.endsWith(URL_PATH_SEPARATOR)) { + path = path + URL_PATH_SEPARATOR; } - if (segment.startsWith("/")) { + if (segment.startsWith(URL_PATH_SEPARATOR)) { segment = segment.substring(1); } - return path+segment; + return path + segment; } } diff --git a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java index e1b1bab770..3883453738 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java @@ -23,14 +23,14 @@ public class StringUtilsTest { @Test public void appendSegmentToPath() { - Assert.assertEquals(StringUtils.appendSegmentToPath("s1","s2"), "s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToPath("s1/","s2"), "s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToPath("s1/","/s2"), "s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToPath("/s1","/s2"), "/s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/","/s2"), "/s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/","/s2/"), "/s1/s2/"); - Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/",null), "/s1/"); - Assert.assertEquals(StringUtils.appendSegmentToPath(null,"/s2/"), "/s2/"); - Assert.assertNull(StringUtils.appendSegmentToPath(null,null)); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("s1","s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("s1/","s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("s1/","/s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1","/s2"), "/s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2"), "/s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2/"), "/s1/s2/"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1/",null), "/s1/"); + Assert.assertEquals(StringUtils.appendSegmentToUrlPath(null,"/s2/"), "/s2/"); + Assert.assertNull(StringUtils.appendSegmentToUrlPath(null,null)); } } diff --git a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java index 9445c64406..548cdbc270 100644 --- a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java +++ b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java @@ -371,7 +371,11 @@ public String getContentType() { * @return The accept header value */ public String getAccept() { - final Object accept = getHeader("Accept"); + Object accept = getHeader("Accept"); + + if (accept == null) { + accept = getHeader("accept"); + } if (accept != null) { return accept.toString(); diff --git a/pom.xml b/pom.xml index 5043e6ab0c..8c69dec341 100644 --- a/pom.xml +++ b/pom.xml @@ -192,6 +192,7 @@ 1.10.14 4.6.0 1.1.27 + com.atlassian.oai 1.8.0 3.25.1 1.78.1 @@ -581,6 +582,18 @@ ${apicurio.data-models.version} + + com.atlassian.oai + swagger-request-validator-core + ${swagger-request-validator.version} + + + commons-logging + commons-logging + + + + org.eclipse.jetty @@ -1183,6 +1196,12 @@ mockito-junit-jupiter test + + uk.org.webcompere + system-stubs-core + 2.1.6 + test + diff --git a/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java b/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java index dcdb87870b..1a4c5d7107 100644 --- a/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java +++ b/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java @@ -116,7 +116,8 @@ public void run(final IHookCallBack callBack, ITestResult testResult) { * @param methodTestLoaders * @param invocationCount */ - protected void run(ITestResult testResult, Method method, List methodTestLoaders, int invocationCount) { + protected void run(ITestResult testResult, Method method, List methodTestLoaders, + int invocationCount) { if (citrus == null) { citrus = Citrus.newInstance(new CitrusSpringContextProvider(applicationContext)); CitrusAnnotations.injectCitrusFramework(this, citrus); @@ -164,6 +165,9 @@ protected void run(ITestResult testResult, Method method, List metho @BeforeClass(alwaysRun = true) public final void before() { + // We need to consider the possibility, that one test has meanwhile modified the current citrus instance, + // as there can be plenty of tests running between @BeforeSuite and the execution of an actual subclass of + // this support. The citrus instance may even have a mocked context. if (citrus == null) { citrus = Citrus.newInstance(new CitrusSpringContextProvider(applicationContext)); CitrusAnnotations.injectCitrusFramework(this, citrus); @@ -206,7 +210,7 @@ public final void beforeSuite() { CitrusAnnotations.injectCitrusFramework(this, citrus); beforeSuite(citrus.getCitrusContext()); citrus.beforeSuite(Reporter.getCurrentTestResult().getTestContext().getSuite().getName(), - Reporter.getCurrentTestResult().getTestContext().getIncludedGroups()); + Reporter.getCurrentTestResult().getTestContext().getIncludedGroups()); } /** From 00ea177e950fdaf12a8fc40af6fd8a8037160d7c Mon Sep 17 00:00:00 2001 From: Timon Borter Date: Mon, 24 Jun 2024 19:22:06 +0200 Subject: [PATCH 4/7] chore(#1175): minor adjustments on imports and code style --- .../openapi/OpenApiPathRegistry.java | 10 ++-- .../openapi/OpenApiRepository.java | 12 ++--- .../openapi/OpenApiResourceLoader.java | 45 ++++++----------- .../openapi/OpenApiSpecification.java | 37 +++++++------- .../openapi/OpenApiSpecificationAdapter.java | 18 +------ .../OpenApiSpecificationProcessor.java | 3 +- .../openapi/OpenApiSupport.java | 6 +-- .../openapi/OpenApiTestDataGenerator.java | 49 +++---------------- .../citrusframework/openapi/OpenApiUtils.java | 6 +-- .../openapi/actions/OpenApiActionBuilder.java | 9 ++-- .../OpenApiClientRequestActionBuilder.java | 9 ++-- .../OpenApiClientResponseActionBuilder.java | 10 ++-- .../OpenApiServerRequestActionBuilder.java | 28 +++++------ .../OpenApiServerResponseActionBuilder.java | 49 ++++++++++--------- .../openapi/model/OasAdapter.java | 19 +------ .../openapi/model/OasModelHelper.java | 16 +++--- .../openapi/model/OpenApiVersion.java | 4 +- .../openapi/model/OperationPathAdapter.java | 4 +- .../openapi/model/v2/Oas20ModelHelper.java | 7 +-- .../openapi/model/v3/Oas30ModelHelper.java | 25 ++++------ .../validation/OpenApiRequestValidator.java | 13 ++--- .../validation/OpenApiResponseValidator.java | 4 +- .../citrusframework/openapi/xml/OpenApi.java | 8 +-- .../citrusframework/openapi/yaml/OpenApi.java | 10 ++-- .../openapi/OpenApiPathRegistryTest.java | 13 ++--- .../openapi/OpenApiRepositoryTest.java | 17 ++++--- .../openapi/OpenApiSettingsTest.java | 10 ++-- .../OpenApiSpecificationAdapterTest.java | 10 ++-- .../openapi/OpenApiSpecificationTest.java | 32 ++++++------ .../openapi/OpenApiTestDataGeneratorTest.java | 9 ++-- .../actions/OpenApiActionBuilderTest.java | 4 +- .../openapi/groovy/OpenApiClientTest.java | 10 ++-- .../openapi/groovy/OpenApiServerTest.java | 4 +- .../openapi/integration/OpenApiClientIT.java | 10 ++-- .../openapi/integration/OpenApiServerIT.java | 17 +++---- .../model/OperationPathAdapterTest.java | 8 --- .../model/v2/Oas20ModelHelperTest.java | 11 +++-- .../model/v3/Oas30ModelHelperTest.java | 15 +++--- ...OpenApiRequestValidationProcessorTest.java | 19 +++---- .../OpenApiRequestValidatorTest.java | 27 ++++++---- ...penApiResponseValidationProcessorTest.java | 7 ++- .../OpenApiResponseValidatorTest.java | 21 +++++--- .../openapi/xml/OpenApiClientTest.java | 10 ++-- .../openapi/xml/OpenApiServerTest.java | 4 +- .../openapi/yaml/OpenApiClientTest.java | 10 ++-- .../openapi/yaml/OpenApiServerTest.java | 4 +- 46 files changed, 299 insertions(+), 374 deletions(-) diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java index 1c05877806..6218baaceb 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java @@ -16,16 +16,17 @@ package org.citrusframework.openapi; -import static java.lang.String.format; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; -import org.citrusframework.exceptions.CitrusRuntimeException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + +import static java.lang.String.format; /** * A registry to store objects by OpenApi paths. The registry uses a digital tree data structure @@ -187,5 +188,4 @@ class RegistryNode { String path; T value = null; } - } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java index 36f113ea83..083da342ca 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java @@ -16,6 +16,11 @@ package org.citrusframework.openapi; +import org.citrusframework.repository.BaseRepository; +import org.citrusframework.spi.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.File; import java.net.MalformedURLException; import java.net.URL; @@ -24,10 +29,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -import org.citrusframework.repository.BaseRepository; -import org.citrusframework.spi.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * OpenApi repository holding a set of {@link OpenApiSpecification} known in the test scope. @@ -140,7 +141,4 @@ static Optional determineResourceAlias(Resource openApiResource) { public List getOpenApiSpecifications() { return openApiSpecifications; } - - - } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java index e78c8160f1..ad3e4bc971 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java @@ -21,15 +21,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.apicurio.datamodels.Library; import io.apicurio.datamodels.openapi.models.OasDocument; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import java.security.KeyManagementException; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.util.Objects; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.http.HttpHeaders; @@ -40,6 +31,16 @@ import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + /** * Loads Open API specifications from different locations like file resource or web resource. * @author Christoph Deppisch @@ -48,7 +49,6 @@ public final class OpenApiResourceLoader { static final RawResolver RAW_RESOLVER = new RawResolver(); - static final OasResolver OAS_RESOLVER = new OasResolver(); /** @@ -60,8 +60,6 @@ private OpenApiResourceLoader() { /** * Loads the specification from a file resource. Either classpath or file system resource path is supported. - * @param resource - * @return */ public static OasDocument fromFile(String resource) { return fromFile(FileUtils.getFileResource(resource), OAS_RESOLVER); @@ -69,8 +67,6 @@ public static OasDocument fromFile(String resource) { /** * Loads the raw specification from a file resource. Either classpath or file system resource path is supported. - * @param resource - * @return */ public static String rawFromFile(String resource) { return fromFile(FileUtils.getFileResource(resource), @@ -79,8 +75,6 @@ public static String rawFromFile(String resource) { /** * Loads the specification from a resource. - * @param resource - * @return */ public static OasDocument fromFile(Resource resource) { return fromFile(resource, OAS_RESOLVER); @@ -88,8 +82,6 @@ public static OasDocument fromFile(Resource resource) { /** * Loads the raw specification from a resource. - * @param resource - * @return */ public static String rawFromFile(Resource resource) { return fromFile(resource, RAW_RESOLVER); @@ -105,8 +97,6 @@ private static T fromFile(Resource resource, Resolver resolver) { /** * Loads specification from given web URL location. - * @param url - * @return */ public static OasDocument fromWebResource(URL url) { return fromWebResource(url, OAS_RESOLVER); @@ -114,8 +104,6 @@ public static OasDocument fromWebResource(URL url) { /** * Loads raw specification from given web URL location. - * @param url - * @return */ public static String rawFromWebResource(URL url) { return fromWebResource(url, RAW_RESOLVER); @@ -130,13 +118,13 @@ private static T fromWebResource(URL url, Resolver resolver) { int status = con.getResponseCode(); if (status > 299) { - throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), + throw new IllegalStateException("Failed to retrieve Open API specification: " + url, new IOException(FileUtils.readToString(con.getErrorStream()))); } else { return resolve(FileUtils.readToString(con.getInputStream()), resolver); } } catch (IOException e) { - throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), e); + throw new IllegalStateException("Failed to retrieve Open API specification: " + url, e); } finally { if (con != null) { con.disconnect(); @@ -146,8 +134,6 @@ private static T fromWebResource(URL url, Resolver resolver) { /** * Loads specification from given web URL location using secured Http connection. - * @param url - * @return */ public static OasDocument fromSecuredWebResource(URL url) { return fromSecuredWebResource(url, OAS_RESOLVER); @@ -155,8 +141,6 @@ public static OasDocument fromSecuredWebResource(URL url) { /** * Loads raw specification from given web URL location using secured Http connection. - * @param url - * @return */ public static String rawFromSecuredWebResource(URL url) { return fromSecuredWebResource(url, RAW_RESOLVER); @@ -181,7 +165,7 @@ private static T fromSecuredWebResource(URL url, Resolver resolver) { int status = con.getResponseCode(); if (status > 299) { - throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), + throw new IllegalStateException("Failed to retrieve Open API specification: " + url, new IOException(FileUtils.readToString(con.getErrorStream()))); } else { return resolve(FileUtils.readToString(con.getInputStream()), resolver); @@ -189,7 +173,7 @@ private static T fromSecuredWebResource(URL url, Resolver resolver) { } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) { throw new IllegalStateException("Failed to create https client for ssl connection", e); } catch (IOException e) { - throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), e); + throw new IllegalStateException("Failed to retrieve Open API specification: " + url, e); } finally { if (con != null) { con.disconnect(); @@ -256,5 +240,4 @@ public String resolveFromNode(JsonNode node) { } } } - } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java index 918aee6f6c..cd70694f74 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java @@ -16,26 +16,11 @@ package org.citrusframework.openapi; -import static org.citrusframework.openapi.OpenApiSettings.isGenerateOptionalFieldsGlobally; -import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; -import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; -import static org.citrusframework.openapi.OpenApiSettings.isValidateOptionalFieldsGlobally; - import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.OpenApiInteractionValidator.Builder; import io.apicurio.datamodels.core.models.common.Info; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.client.HttpClient; @@ -49,6 +34,23 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import static org.citrusframework.openapi.OpenApiSettings.isGenerateOptionalFieldsGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isValidateOptionalFieldsGlobally; + /** * OpenApi specification resolves URL or local file resources to a specification document. *

@@ -209,7 +211,6 @@ public synchronized OasDocument getOpenApiDoc(TestContext context) { } if (resolvedSpecUrl.startsWith(HTTP)) { - URL specWebResource = toSpecUrl(resolvedSpecUrl); if (resolvedSpecUrl.startsWith(HTTPS)) { initApiDoc( @@ -257,10 +258,10 @@ public synchronized OasDocument getOpenApiDoc(TestContext context) { // provided for testing URL toSpecUrl(String resolvedSpecUrl) { try { - return new URL(resolvedSpecUrl); + return URI.create(resolvedSpecUrl).toURL(); } catch (MalformedURLException e) { throw new IllegalStateException( - "Failed to retrieve Open API specification as web resource: " + specUrl, e); + "Failed to retrieve Open API specification as web resource: " + resolvedSpecUrl, e); } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java index fa3eaca62c..011a2dc7fd 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java @@ -22,22 +22,6 @@ * * @param the type to which the specification is adapted. */ -public class OpenApiSpecificationAdapter { - - private final OpenApiSpecification openApiSpecification; +public record OpenApiSpecificationAdapter(OpenApiSpecification openApiSpecification, T entity) { - private final T entity; - - public OpenApiSpecificationAdapter(OpenApiSpecification openApiSpecification, T entity) { - this.openApiSpecification = openApiSpecification; - this.entity = entity; - } - - public OpenApiSpecification getOpenApiSpecification() { - return openApiSpecification; - } - - public T getEntity() { - return entity; - } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java index b7ca0b5bdc..ceeb9286d5 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java @@ -16,12 +16,13 @@ package org.citrusframework.openapi; -import java.util.Map; import org.citrusframework.spi.ResourcePathTypeResolver; import org.citrusframework.spi.TypeResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Map; + /** * Interface for processing OpenAPI specifications. *

diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java index e966960fdf..9020d555e3 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java @@ -16,9 +16,6 @@ package org.citrusframework.openapi; -import java.util.Collection; -import java.util.Map; - import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -33,6 +30,9 @@ import org.yaml.snakeyaml.nodes.Tag; import org.yaml.snakeyaml.representer.Representer; +import java.util.Collection; +import java.util.Map; + public class OpenApiSupport { private static final ObjectMapper OBJECT_MAPPER; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java index 0a0795db8b..30b0e45247 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java @@ -17,8 +17,7 @@ package org.citrusframework.openapi; import io.apicurio.datamodels.openapi.models.OasSchema; -import java.util.Map; -import java.util.stream.Collectors; +import jakarta.annotation.Nullable; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -26,6 +25,9 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import java.util.Map; +import java.util.stream.Collectors; + /** * Generates proper payloads and validation expressions based on Open API specification rules. Creates outbound payloads * with generated random test data according to specification and creates inbound payloads with proper validation expressions to @@ -37,9 +39,6 @@ public class OpenApiTestDataGenerator { /** * Creates payload from schema for outbound message. - * @param schema - * @param definitions - * @return */ public static String createOutboundPayload(OasSchema schema, Map definitions, OpenApiSpecification specification) { @@ -82,10 +81,6 @@ public static String createOutboundPayload(OasSchema schema, Map definitions, boolean quotes, OpenApiSpecification specification, TestContext context) { @@ -107,10 +102,6 @@ public static T createRawRandomValueExpression(String name, OasSchema schema /** * Create payload from schema with random values. - * @param schema - * @param definitions - * @param quotes - * @return */ public static String createRandomValueExpression(OasSchema schema, Map definitions, boolean quotes, OpenApiSpecification specification) { @@ -180,9 +171,6 @@ public static T createRawRandomValueExpression(OasSchema schema, Map definitions, OpenApiSpecification specification) { @@ -225,9 +213,6 @@ public static String createInboundPayload(OasSchema schema, Map definitions, boolean quotes, OpenApiSpecification specification, @@ -258,10 +237,6 @@ public static String createValidationExpression(String name, OasSchema schema, M /** * Create validation expression using functions according to schema type and format. - * @param schema - * @param definitions - * @param quotes - * @return */ public static String createValidationExpression(OasSchema schema, Map definitions, boolean quotes, OpenApiSpecification specification) { @@ -308,8 +283,6 @@ public static String createValidationExpression(OasSchema schema, Map getDelegate() { /** * Specifies the referenceResolver. - * @param referenceResolver */ @Override - public void setReferenceResolver(ReferenceResolver referenceResolver) { + public void setReferenceResolver(@Nullable ReferenceResolver referenceResolver) { if (referenceResolver != null) { this.referenceResolver = referenceResolver; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java index e18f0d8f80..002058b8f1 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java @@ -19,10 +19,6 @@ import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; import io.apicurio.datamodels.openapi.models.OasSchema; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -38,6 +34,11 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + /** * @author Christoph Deppisch * @since 4.1 diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java index a0b3a3433f..129f118052 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java @@ -20,10 +20,6 @@ import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import jakarta.annotation.Nullable; -import java.util.Collection; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -41,6 +37,11 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; + /** * @author Christoph Deppisch * @since 4.1 @@ -48,6 +49,7 @@ public class OpenApiClientResponseActionBuilder extends HttpClientResponseActionBuilder { private final OpenApiResponseValidationProcessor openApiResponseValidationProcessor; + /** * Default constructor initializes http response message builder. */ diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java index 518ea02ad3..43816b8c37 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java @@ -16,21 +16,9 @@ package org.citrusframework.openapi.actions; -import static java.lang.String.format; -import static org.citrusframework.message.MessageType.JSON; -import static org.citrusframework.message.MessageType.PLAINTEXT; -import static org.citrusframework.message.MessageType.XML; -import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType; -import static org.citrusframework.util.StringUtils.appendSegmentToUrlPath; -import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; -import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; - import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; import io.apicurio.datamodels.openapi.models.OasSchema; -import java.util.List; -import java.util.Optional; -import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -46,6 +34,19 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +import static java.lang.String.format; +import static org.citrusframework.message.MessageType.JSON; +import static org.citrusframework.message.MessageType.PLAINTEXT; +import static org.citrusframework.message.MessageType.XML; +import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType; +import static org.citrusframework.util.StringUtils.appendSegmentToUrlPath; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; + /** * @author Christoph Deppisch * @since 4.1 @@ -233,8 +234,5 @@ private void setSpecifiedMessageType(OperationPathAdapter operationPathAdapter) private void setSpecifiedMethod(OperationPathAdapter operationPathAdapter) { httpMessage.method(HttpMethod.valueOf(operationPathAdapter.operation().getMethod().toUpperCase())); } - } - - } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java index 6896413d5d..46cb3fbcab 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java @@ -16,27 +16,10 @@ package org.citrusframework.openapi.actions; -import static java.lang.Integer.parseInt; -import static java.util.Collections.singletonMap; -import static org.citrusframework.openapi.OpenApiTestDataGenerator.createOutboundPayload; -import static org.citrusframework.openapi.OpenApiTestDataGenerator.createRandomValueExpression; -import static org.springframework.http.HttpStatus.OK; -import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; -import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; - import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; -import java.util.function.Predicate; -import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; @@ -54,6 +37,24 @@ import org.citrusframework.openapi.validation.OpenApiResponseValidationProcessor; import org.springframework.http.HttpStatus; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static java.lang.Integer.parseInt; +import static java.util.Collections.singletonMap; +import static org.citrusframework.openapi.OpenApiTestDataGenerator.createOutboundPayload; +import static org.citrusframework.openapi.OpenApiTestDataGenerator.createRandomValueExpression; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; + /** * @author Christoph Deppisch * @since 4.1 @@ -219,26 +220,26 @@ private void buildRandomPayload(OasOperation operation, OasDocument oasDocument, // If we have a schema and a media type and the content type has not yet been set, do it. // If schema is null, we do not set the content type, as there is no content. - if (!getMessage().getHeaders().containsKey(HttpMessageHeaders.HTTP_CONTENT_TYPE) && schemaForMediaType.getAdapted() != null && schemaForMediaType.getNode() != null) { - addHeaderBuilder(new DefaultHeaderBuilder(singletonMap(HttpMessageHeaders.HTTP_CONTENT_TYPE, schemaForMediaType.getAdapted()))); + if (!getMessage().getHeaders().containsKey(HttpMessageHeaders.HTTP_CONTENT_TYPE) && schemaForMediaType.adapted() != null && schemaForMediaType.node() != null) { + addHeaderBuilder(new DefaultHeaderBuilder(singletonMap(HttpMessageHeaders.HTTP_CONTENT_TYPE, schemaForMediaType.adapted()))); } } } private void createRandomPayload(HttpMessage message, OasDocument oasDocument, OasAdapter schemaForMediaType) { - if (schemaForMediaType.getNode() == null) { + if (schemaForMediaType.node() == null) { // No schema means no payload, no type message.setPayload(null); } else { - if (TEXT_PLAIN_VALUE.equals(schemaForMediaType.getAdapted())) { + if (TEXT_PLAIN_VALUE.equals(schemaForMediaType.adapted())) { // Schema but plain text - message.setPayload(createOutboundPayload(schemaForMediaType.getNode(), + message.setPayload(createOutboundPayload(schemaForMediaType.node(), OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)); message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, TEXT_PLAIN_VALUE); - } else if (APPLICATION_JSON_VALUE.equals(schemaForMediaType.getAdapted())) { + } else if (APPLICATION_JSON_VALUE.equals(schemaForMediaType.adapted())) { // Json Schema - message.setPayload(createOutboundPayload(schemaForMediaType.getNode(), + message.setPayload(createOutboundPayload(schemaForMediaType.node(), OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)); message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, APPLICATION_JSON_VALUE); } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java index a3f6fa8c52..9933ecebc8 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java @@ -2,23 +2,6 @@ import io.apicurio.datamodels.core.models.Node; -public class OasAdapter { - - private final S node; - - private final T adapted; - - public OasAdapter(S node, T adapted) { - this.node = node; - this.adapted = adapted; - } - - public S getNode() { - return node; - } - - public T getAdapted() { - return adapted; - } +public record OasAdapter(S node, T adapted) { } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java index ddf0a74886..97404d7594 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.model; -import static java.util.Collections.singletonList; - import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; @@ -36,6 +34,11 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; import jakarta.annotation.Nullable; +import org.citrusframework.openapi.model.v2.Oas20ModelHelper; +import org.citrusframework.openapi.model.v3.Oas30ModelHelper; +import org.citrusframework.util.StringUtils; +import org.springframework.http.MediaType; + import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -48,10 +51,8 @@ import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; -import org.citrusframework.openapi.model.v2.Oas20ModelHelper; -import org.citrusframework.openapi.model.v3.Oas30ModelHelper; -import org.citrusframework.util.StringUtils; -import org.springframework.http.MediaType; + +import static java.util.Collections.singletonList; /** * @author Christoph Deppisch @@ -309,7 +310,8 @@ public static Optional getResponseForRandomGeneration(OasDocument o // Fallback 4: Pick the first response no matter which schema response = operation.responses.getResponses().stream() .map(resp -> responseMap.get(resp.getStatusCode())) - .filter(Objects::nonNull).findFirst(); + .filter(Objects::nonNull) + .findFirst(); } return response; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java index 13e4a74008..f9b9727b4c 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java @@ -16,12 +16,12 @@ package org.citrusframework.openapi.model; -import java.util.Arrays; - import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.v2.models.Oas20Document; import io.apicurio.datamodels.openapi.v3.models.Oas30Document; +import java.util.Arrays; + /** * List of supported OpenAPI specification versions and their corresponding model document types. */ diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java index 7d943af929..987ed05c90 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java @@ -16,11 +16,11 @@ package org.citrusframework.openapi.model; -import static java.lang.String.format; - import io.apicurio.datamodels.openapi.models.OasOperation; import org.citrusframework.openapi.OpenApiUtils; +import static java.lang.String.format; + /** * Adapts the different paths associated with an OpenAPI operation to the {@link OasOperation}. * This record holds the API path, context path, full path, and the associated {@link OasOperation} object. diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java index dd5780fdd0..bb0cdf977f 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java @@ -26,16 +26,17 @@ import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; import io.apicurio.datamodels.openapi.v2.models.Oas20SchemaDefinition; -import java.util.Arrays; import jakarta.annotation.Nullable; +import org.citrusframework.openapi.model.OasAdapter; +import org.citrusframework.openapi.model.OasModelHelper; + +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; -import org.citrusframework.openapi.model.OasAdapter; -import org.citrusframework.openapi.model.OasModelHelper; /** * @author Christoph Deppisch diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java index 23e4c20061..75b977b9f5 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java @@ -26,8 +26,13 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.citrusframework.openapi.model.OasAdapter; +import org.citrusframework.openapi.model.OasModelHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.net.MalformedURLException; -import java.net.URL; +import java.net.URI; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -37,10 +42,6 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; -import org.citrusframework.openapi.model.OasAdapter; -import org.citrusframework.openapi.model.OasModelHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * @author Christoph Deppisch @@ -62,11 +63,7 @@ public static String getHost(Oas30Document openApiDoc) { String serverUrl = resolveUrl(openApiDoc.servers.get(0)); if (serverUrl.startsWith("http")) { - try { - return new URL(serverUrl).getHost(); - } catch (MalformedURLException e) { - throw new IllegalStateException(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); - } + return URI.create(serverUrl).getHost(); } return "localhost"; @@ -81,7 +78,7 @@ public static List getSchemes(Oas30Document openApiDoc) { .map(Oas30ModelHelper::resolveUrl) .map(serverUrl -> { try { - return new URL(serverUrl).getProtocol(); + return URI.create(serverUrl).toURL().getProtocol(); } catch (MalformedURLException e) { LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); return null; @@ -101,11 +98,7 @@ public static String getBasePath(Oas30Document openApiDoc) { String serverUrl = resolveUrl(server); if (serverUrl.startsWith("http")) { - try { - basePath = new URL(serverUrl).getPath(); - } catch (MalformedURLException e) { - throw new IllegalStateException(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); - } + basePath = URI.create(serverUrl).getPath(); } else { basePath = serverUrl; } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java index 6948c793d8..bef2c35230 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java @@ -16,19 +16,20 @@ package org.citrusframework.openapi.validation; -import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; - import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request; import com.atlassian.oai.validator.model.SimpleRequest; import com.atlassian.oai.validator.report.ValidationReport; -import java.util.ArrayList; -import java.util.Collection; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageHeaders; import org.citrusframework.openapi.model.OperationPathAdapter; +import java.util.ArrayList; +import java.util.Collection; + +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; + /** * Specific validator that uses atlassian and is responsible for validating HTTP requests * against an OpenAPI specification using the provided {@code OpenApiInteractionValidator}. @@ -83,8 +84,8 @@ Request createRequestFromMessage(OperationPathAdapter operationPathAdapter, value))); httpMessage.getHeaders().forEach((key, value) -> { - if (value instanceof Collection) { - ((Collection) value).forEach( v -> finalRequestBuilder.withHeader(key, v != null ? v.toString() : null)); + if (value instanceof Collection collection) { + collection.forEach( v -> finalRequestBuilder.withHeader(key, v != null ? v.toString() : null)); } else { finalRequestBuilder.withHeader(key, value != null ? value.toString() : null); diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java index db4a41e375..9aba9b0764 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.validation; -import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; - import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request.Method; import com.atlassian.oai.validator.model.Response; @@ -28,6 +26,8 @@ import org.citrusframework.openapi.model.OperationPathAdapter; import org.springframework.http.HttpStatusCode; +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; + /** * Specific validator, that facilitates the use of Atlassian's Swagger Request Validator, * and delegates validation of OpenApi requests to instances of {@link OpenApiRequestValidator}. diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java index dfd837655a..96885ae516 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java @@ -16,10 +16,6 @@ package org.citrusframework.openapi.xml; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAttribute; @@ -46,6 +42,10 @@ import org.citrusframework.xml.actions.Receive; import org.citrusframework.xml.actions.Send; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + /** * @author Christoph Deppisch */ diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java index 59f92bd9ff..21a02a1950 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java @@ -16,10 +16,6 @@ package org.citrusframework.openapi.yaml; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - import org.citrusframework.TestAction; import org.citrusframework.TestActionBuilder; import org.citrusframework.actions.ReceiveMessageAction; @@ -36,9 +32,13 @@ import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; import org.citrusframework.spi.ReferenceResolver; import org.citrusframework.spi.ReferenceResolverAware; +import org.citrusframework.yaml.actions.Message; import org.citrusframework.yaml.actions.Receive; import org.citrusframework.yaml.actions.Send; -import org.citrusframework.yaml.actions.Message; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; /** * @author Christoph Deppisch diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java index 9d811711f6..d837f1f0ee 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java @@ -16,10 +16,8 @@ package org.citrusframework.openapi; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; +import org.testng.Assert; +import org.testng.annotations.Test; import java.util.ArrayList; import java.util.HashSet; @@ -28,8 +26,11 @@ import java.util.Random; import java.util.Set; import java.util.stream.Collectors; -import org.testng.Assert; -import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; public class OpenApiPathRegistryTest { diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java index 9185cc6081..fe61791bf2 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java @@ -16,6 +16,15 @@ package org.citrusframework.openapi; +import org.citrusframework.spi.Resource; +import org.testng.annotations.Test; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Optional; + import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; @@ -24,14 +33,6 @@ import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; -import java.io.File; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.List; -import java.util.Optional; -import org.citrusframework.spi.Resource; -import org.testng.annotations.Test; - public class OpenApiRepositoryTest { private static final String ROOT = "/root"; diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java index c96cd5f5b6..d4e448ec26 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java @@ -1,5 +1,10 @@ package org.citrusframework.openapi; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; + import static org.citrusframework.openapi.OpenApiSettings.GENERATE_OPTIONAL_FIELDS_PROPERTY; import static org.citrusframework.openapi.OpenApiSettings.REQUEST_VALIDATION_ENABLED_PROPERTY; import static org.citrusframework.openapi.OpenApiSettings.RESPONSE_VALIDATION_ENABLED_PROPERTY; @@ -8,11 +13,6 @@ import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; -import org.testng.annotations.AfterMethod; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; -import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; - public class OpenApiSettingsTest { private final EnvironmentVariables environmentVariables = new EnvironmentVariables(); diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java index 6693078ef2..65c1433bc4 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java @@ -1,14 +1,14 @@ package org.citrusframework.openapi; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; - import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + public class OpenApiSpecificationAdapterTest { @Mock @@ -34,14 +34,14 @@ public void tearDown() throws Exception { @Test public void shouldProvideOpenApiSpecification() { - OpenApiSpecification specification = openApiSpecificationAdapter.getOpenApiSpecification(); + OpenApiSpecification specification = openApiSpecificationAdapter.openApiSpecification(); assertNotNull(specification, "OpenApiSpecification should not be null"); assertEquals(specification, openApiSpecificationMock, "OpenApiSpecification should match the mock"); } @Test public void shouldProvideEntity() { - Object entity = openApiSpecificationAdapter.getEntity(); + Object entity = openApiSpecificationAdapter.entity(); assertNotNull(entity, "Entity should not be null"); assertEquals(entity, entityMock, "Entity should match the mock"); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java index 5a8cb34c45..05f22c522a 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java @@ -1,12 +1,7 @@ package org.citrusframework.openapi; import io.apicurio.datamodels.openapi.models.OasDocument; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import javax.net.ssl.HttpsURLConnection; import org.citrusframework.context.TestContext; -import org.citrusframework.endpoint.EndpointConfiguration; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpEndpointConfiguration; @@ -23,12 +18,23 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import javax.net.ssl.HttpsURLConnection; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.Optional; import static org.citrusframework.util.FileUtils.readToString; -import static org.mockito.Mockito.*; -import static org.testng.Assert.*; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.Mockito.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; public class OpenApiSpecificationTest { @@ -211,21 +217,21 @@ public static Object[][] specSources() { } @Test(dataProvider = "lazyInitializationDataprovider") - public void shouldDisableEnableRequestValidationWhenSet(String requestUrl, String specSource) throws IOException { + public void shouldDisableEnableRequestValidationWhenSet(String requestUrl, String specSource) { // Given OpenApiSpecification specification = new OpenApiSpecification() { + @Override URL toSpecUrl(String resolvedSpecUrl) { return mockUrlConnection(resolvedSpecUrl); } }; + specification.setRequestUrl(requestUrl); specification.setHttpClient("sampleHttpClient"); specification.setSpecUrl(specSource); - when(testContextMock.replaceDynamicContentInString(isA(String.class))).thenAnswer(answer-> - answer.getArgument(0) - ); + when(testContextMock.replaceDynamicContentInString(isA(String.class))).thenAnswer(returnsFirstArg()); when(testContextMock.getReferenceResolver()).thenReturn(referenceResolverMock); when(referenceResolverMock.isResolvable("sampleHttpClient", HttpClient.class)).thenReturn(true); @@ -233,9 +239,6 @@ URL toSpecUrl(String resolvedSpecUrl) { when(httpClient.getEndpointConfiguration()).thenReturn(endpointConfigurationMock); when(endpointConfigurationMock.getRequestUrl()).thenReturn("http://org.citrus.sample"); - boolean sampleHttpCient = testContextMock.getReferenceResolver() - .isResolvable("sampleHttpClient", HttpClient.class); - // When specification.setRequestValidationEnabled(false); @@ -383,5 +386,4 @@ public void shouldReturnSpecUrlInAbsenceOfRequestUrl() { assertEquals(openApiSpecification.getRequestUrl(), "http://or.citrus.sample"); } - } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java index 89d19ac670..ab063e27c9 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java @@ -16,17 +16,18 @@ package org.citrusframework.openapi; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; - import com.fasterxml.jackson.core.JsonProcessingException; import io.apicurio.datamodels.openapi.models.OasSchema; -import java.util.Map; import org.citrusframework.openapi.model.OasModelHelper; import org.citrusframework.spi.Resources; import org.testng.Assert; import org.testng.annotations.Test; +import java.util.Map; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; + // TODO: Add more tests public class OpenApiTestDataGeneratorTest { diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java index 0b37361d0c..c3406e1555 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java @@ -16,12 +16,12 @@ package org.citrusframework.openapi.actions; -import java.util.Map; - import org.citrusframework.TestActionBuilder; import org.testng.Assert; import org.testng.annotations.Test; +import java.util.Map; + /** * @author Christoph Deppisch */ diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java index 83e9f20076..e0dd063102 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java @@ -16,11 +16,6 @@ package org.citrusframework.openapi.groovy; -import java.io.IOException; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ArrayBlockingQueue; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -57,6 +52,11 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.io.IOException; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; + import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; /** diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java index 3bd7e60ec1..c90aedaee4 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.groovy; -import java.util.Map; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -45,6 +43,8 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.util.Map; + import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; import static org.testng.Assert.assertEquals; diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java index 7e66c3f0bf..574de92ccb 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java @@ -16,11 +16,6 @@ package org.citrusframework.openapi.integration; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; -import static org.testng.Assert.assertThrows; -import static org.testng.Assert.fail; - import org.citrusframework.annotations.CitrusTest; import org.citrusframework.exceptions.TestCaseFailedException; import org.citrusframework.http.actions.HttpClientRequestActionBuilder.HttpMessageBuilderSupport; @@ -38,6 +33,11 @@ import org.springframework.http.HttpStatus; import org.testng.annotations.Test; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + /** * @author Christoph Deppisch */ diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java index c98af2553f..ede53a1e8b 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java @@ -16,21 +16,12 @@ package org.citrusframework.openapi.integration; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; -import static org.testng.Assert.assertThrows; -import static org.testng.Assert.fail; - -import java.util.List; import org.citrusframework.annotations.CitrusTest; import org.citrusframework.exceptions.TestCaseFailedException; -import org.citrusframework.exceptions.ValidationException; -import org.citrusframework.http.actions.HttpServerResponseActionBuilder.HttpMessageBuilderSupport; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpClientBuilder; import org.citrusframework.http.server.HttpServer; import org.citrusframework.http.server.HttpServerBuilder; -import org.citrusframework.openapi.OpenApiRepository; import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.actions.OpenApiActionBuilder; import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; @@ -40,9 +31,13 @@ import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; import org.citrusframework.util.SocketUtils; import org.springframework.http.HttpStatus; -import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + /** * @author Christoph Deppisch */ @@ -173,7 +168,7 @@ public void shouldFailOnWrongQueryIdTypeWithOasDisabled() { assertThrows(TestCaseFailedException.class, () -> then(addPetBuilder)); } - @CitrusTest + @CitrusTest public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { variable("petId", "xxx"); diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java index cec2e46744..7846daf42b 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java @@ -1,22 +1,14 @@ package org.citrusframework.openapi.model; -import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; import org.citrusframework.openapi.OpenApiUtils; -import org.citrusframework.openapi.model.OperationPathAdapter; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.testng.annotations.AfterMethod; -import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import static java.lang.String.format; -import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; public class OperationPathAdapterTest { - @Test public void shouldReturnFormattedStringWhenToStringIsCalled() { // Given diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java index f57d3c46ce..501c497f7c 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java @@ -1,8 +1,5 @@ package org.citrusframework.openapi.model.v2; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertTrue; - import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v2.models.Oas20Document; @@ -12,11 +9,15 @@ import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20Responses; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; -import java.util.List; -import java.util.Optional; import org.citrusframework.openapi.model.OasModelHelper; import org.testng.annotations.Test; +import java.util.List; +import java.util.Optional; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + public class Oas20ModelHelperTest { @Test diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java index 9feee74a1a..b26f2f7780 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java @@ -1,9 +1,5 @@ package org.citrusframework.openapi.model.v3; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertSame; -import static org.testng.Assert.assertTrue; - import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v3.models.Oas30Document; @@ -14,13 +10,18 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Response; import io.apicurio.datamodels.openapi.v3.models.Oas30Responses; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; -import java.util.Collection; -import java.util.Map; -import java.util.Optional; import org.citrusframework.openapi.model.OasModelHelper; import org.springframework.http.MediaType; import org.testng.annotations.Test; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + public class Oas30ModelHelperTest { @Test diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java index fc72cd5ade..7c7a578106 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java @@ -1,14 +1,5 @@ package org.citrusframework.openapi.validation; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; import org.citrusframework.context.TestContext; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; @@ -21,6 +12,16 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + public class OpenApiRequestValidationProcessorTest { @Mock diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java index d1decc05a3..9b97d42b78 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java @@ -4,27 +4,34 @@ import com.atlassian.oai.validator.model.Request; import com.atlassian.oai.validator.model.Request.Method; import com.atlassian.oai.validator.report.ValidationReport; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageHeaders; import org.citrusframework.openapi.model.OperationPathAdapter; -import org.citrusframework.openapi.validation.OpenApiRequestValidator; -import org.mockito.*; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; import org.springframework.web.bind.annotation.RequestMethod; import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.testng.Assert.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; public class OpenApiRequestValidatorTest { diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java index a7aabba892..bd60fd55b6 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java @@ -15,7 +15,12 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class OpenApiResponseValidationProcessorTest { diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java index d8d4433447..5bfef2eacb 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java @@ -4,24 +4,31 @@ import com.atlassian.oai.validator.model.Request.Method; import com.atlassian.oai.validator.model.Response; import com.atlassian.oai.validator.report.ValidationReport; -import io.apicurio.datamodels.core.models.common.Operation; import io.apicurio.datamodels.openapi.models.OasOperation; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.openapi.model.OperationPathAdapter; -import org.mockito.*; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; import org.springframework.http.HttpStatusCode; import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.testng.Assert.*; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; public class OpenApiResponseValidatorTest { diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java index 0a14861377..d8ac404c2c 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java @@ -16,11 +16,6 @@ package org.citrusframework.openapi.xml; -import java.io.IOException; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ArrayBlockingQueue; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -60,6 +55,11 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.io.IOException; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; + import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; /** diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java index 4d0fcb7f0f..4c6fb72246 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.xml; -import java.util.Map; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -45,6 +43,8 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.util.Map; + import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java index a6c383d93a..d8a338ef9e 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java @@ -16,11 +16,6 @@ package org.citrusframework.openapi.yaml; -import java.io.IOException; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ArrayBlockingQueue; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -58,6 +53,11 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.io.IOException; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; + import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; /** diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java index 88ee6283c2..e9e6224403 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.yaml; -import java.util.Map; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -45,6 +43,8 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.util.Map; + import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; From 1ce0f758ea4bf844e00dc835e62d67065777f093 Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Tue, 25 Jun 2024 08:36:15 +0200 Subject: [PATCH 5/7] feat(#1175): Add OpenApiRepository and Validation --- .../openapi/OpenApiResourceLoader.java | 4 +- .../openapi/OpenApiTestDataGenerator.java | 9 +++ .../openapi/model/OasModelHelper.java | 25 ++++++++ .../openapi/model/v2/Oas20ModelHelper.java | 12 +++- .../openapi/model/v3/Oas30ModelHelper.java | 5 ++ .../openapi/OpenApiTestDataGeneratorTest.java | 62 ++++++++++++------- 6 files changed, 91 insertions(+), 26 deletions(-) diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java index ad3e4bc971..17c79ba4c3 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java @@ -47,9 +47,9 @@ */ public final class OpenApiResourceLoader { - static final RawResolver RAW_RESOLVER = new RawResolver(); + private static final RawResolver RAW_RESOLVER = new RawResolver(); - static final OasResolver OAS_RESOLVER = new OasResolver(); + private static final OasResolver OAS_RESOLVER = new OasResolver(); /** * Prevent instantiation of utility class. diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java index 30b0e45247..d98ed5d5c9 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java @@ -285,6 +285,15 @@ public static String createValidationExpression(OasSchema schema, Map T delegate(OasParameter parameter, Function generic return value + * @return + */ + private static T delegate(OasSchema schema, Function oas20Function, Function oas30Function) { + if (schema instanceof Oas20Schema oas20Schema) { + return oas20Function.apply(oas20Schema); + } else if (schema instanceof Oas30Schema oas30Schema) { + return oas30Function.apply(oas30Schema); + } + + throw new IllegalArgumentException(String.format("Unsupported operation parameter type: %s", schema.getClass())); + } + /** * Delegate method to version specific model helpers for Open API v2 or v3. * @param operation @@ -537,4 +561,5 @@ public static List resolveAllTypes(@Nullable List acceptedMediaT return acceptedMediaTypes.stream() .flatMap(types -> Arrays.stream(types.split(","))).map(String::trim).toList(); } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java index bb0cdf977f..cb00a6ef46 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java @@ -25,11 +25,9 @@ import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema.Oas20AllOfSchema; import io.apicurio.datamodels.openapi.v2.models.Oas20SchemaDefinition; import jakarta.annotation.Nullable; -import org.citrusframework.openapi.model.OasAdapter; -import org.citrusframework.openapi.model.OasModelHelper; - import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -37,6 +35,8 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import org.citrusframework.openapi.model.OasAdapter; +import org.citrusframework.openapi.model.OasModelHelper; /** * @author Christoph Deppisch @@ -90,6 +90,11 @@ public static Optional> getSchema(Oas20Operation o return selectedSchema == null && selectedMediaType == null ? Optional.empty() : Optional.of(new OasAdapter<>(selectedSchema, selectedMediaType)); } + public static boolean isCompositeSchema(Oas20Schema schema) { + // Note that oneOf and anyOf is not supported by Oas20. + return schema instanceof Oas20AllOfSchema; + } + public static Optional getRequestBodySchema(@Nullable Oas20Document ignoredOpenApiDoc, Oas20Operation operation) { if (operation.parameters == null) { return Optional.empty(); @@ -204,4 +209,5 @@ public static Optional getParameterSchema(Oas20Parameter parameter) { return Optional.of(schema); } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java index 75b977b9f5..e21e242705 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java @@ -88,6 +88,10 @@ public static List getSchemes(Oas30Document openApiDoc) { .toList(); } + public static boolean isCompositeSchema(Oas30Schema schema) { + return schema.anyOf != null || schema.oneOf != null || schema.allOf != null; + } + public static String getBasePath(Oas30Document openApiDoc) { if (openApiDoc.servers == null || openApiDoc.servers.isEmpty()) { return "/"; @@ -248,4 +252,5 @@ private static String resolveUrl(Server server) { public static Optional getParameterSchema(Oas30Parameter parameter) { return Optional.ofNullable((OasSchema) parameter.schema); } + } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java index ab063e27c9..a16ea69d09 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java @@ -16,35 +16,55 @@ package org.citrusframework.openapi; -import com.fasterxml.jackson.core.JsonProcessingException; -import io.apicurio.datamodels.openapi.models.OasSchema; -import org.citrusframework.openapi.model.OasModelHelper; -import org.citrusframework.spi.Resources; -import org.testng.Assert; +import static org.mockito.Mockito.mock; +import static org.testng.Assert.assertEquals; + +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema.Oas20AllOfSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.HashMap; +import java.util.List; import org.testng.annotations.Test; -import java.util.Map; +public class OpenApiTestDataGeneratorTest { -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; + @Test + public void anyOfIsIgnoredForOas3() { -// TODO: Add more tests -public class OpenApiTestDataGeneratorTest { + Oas30Schema anyOfSchema = new Oas30Schema(); + anyOfSchema.anyOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(OpenApiTestDataGenerator.createValidationExpression( + anyOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void allOfIsIgnoredForOas3() { - private final OpenApiSpecification pingSpec = OpenApiSpecification.from( - Resources.create("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + Oas30Schema allOfSchema = new Oas30Schema(); + allOfSchema.allOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(OpenApiTestDataGenerator.createValidationExpression( + allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void oneOfIsIgnoredForOas3() { + + Oas30Schema oneOfSchema = new Oas30Schema(); + oneOfSchema.oneOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(OpenApiTestDataGenerator.createValidationExpression( + oneOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } - // TODO: fix this by introducing mature validation @Test - public void failsToValidateAnyOf() throws JsonProcessingException { + public void allOfIsIgnoredForOas2() { - Map schemaDefinitions = OasModelHelper.getSchemaDefinitions( - pingSpec.getOpenApiDoc(null)); - assertNotNull(schemaDefinitions); - assertFalse(schemaDefinitions.isEmpty()); - Assert.assertEquals(schemaDefinitions.size(), 15); + Oas20AllOfSchema allOfSchema = new Oas20AllOfSchema(); + allOfSchema.allOf = List.of(new Oas20Schema(), new Oas20Schema()); - Assert.assertThrows(() -> OpenApiTestDataGenerator.createValidationExpression( - schemaDefinitions.get("PingRespType"), schemaDefinitions, true, pingSpec)); + assertEquals(OpenApiTestDataGenerator.createValidationExpression( + allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); } } From 8ef618199657d6fb030d53d8246181e9adf9e0ed Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Fri, 5 Jul 2024 08:43:29 +0200 Subject: [PATCH 6/7] feat(#1175): improve random data generation for test data generator --- .../openapi/OpenApiConstants.java | 41 + .../openapi/OpenApiRepository.java | 4 +- .../openapi/OpenApiSettings.java | 18 +- .../openapi/OpenApiSpecification.java | 154 ++-- .../openapi/OpenApiTestDataGenerator.java | 729 ++++++++++++------ .../OpenApiTestValidationDataGenerator.java | 246 ++++++ .../openapi/actions/OpenApiActionBuilder.java | 3 +- .../OpenApiClientRequestActionBuilder.java | 42 +- .../OpenApiClientResponseActionBuilder.java | 56 +- .../OpenApiServerRequestActionBuilder.java | 64 +- .../OpenApiServerResponseActionBuilder.java | 65 +- .../openapi/model/OasModelHelper.java | 35 +- .../openapi/model/OperationPathAdapter.java | 2 +- .../openapi/{ => util}/OpenApiUtils.java | 18 +- .../openapi/util/RandomElement.java | 116 +++ .../openapi/util/RandomModelBuilder.java | 109 +++ .../openapi/util/RandomModelWriter.java | 115 +++ .../OpenApiRequestValidationProcessor.java | 15 +- .../validation/OpenApiRequestValidator.java | 17 +- .../OpenApiResponseValidationProcessor.java | 17 +- .../validation/OpenApiResponseValidator.java | 9 +- .../openapi/validation/OpenApiValidator.java | 12 +- .../SwaggerOpenApiValidationContext.java | 77 ++ ...SwaggerOpenApiValidationContextLoader.java | 78 ++ .../openapi/OpenApiSettingsTest.java | 28 +- .../openapi/OpenApiSpecificationTest.java | 69 +- .../openapi/OpenApiTestDataGeneratorTest.java | 487 +++++++++++- ...penApiTestValidationDataGeneratorTest.java | 71 ++ .../openapi/OpenApiUtilsTest.java | 17 + .../openapi/integration/OpenApiClientIT.java | 79 +- .../openapi/integration/OpenApiServerIT.java | 115 ++- .../model/OperationPathAdapterTest.java | 18 +- .../openapi/util/RandomElementTest.java | 89 +++ .../openapi/util/RandomModelBuilderTest.java | 127 +++ ...OpenApiRequestValidationProcessorTest.java | 104 +-- .../OpenApiRequestValidatorTest.java | 70 +- ...penApiResponseValidationProcessorTest.java | 104 +-- .../OpenApiResponseValidatorTest.java | 33 +- .../openapi/xml/OpenApiClientTest.java | 14 +- .../openapi/petstore/petstore-v3.json | 6 +- .../openapi/ping/ping-api.yaml | 407 +++++++--- core/citrus-base/pom.xml | 6 +- .../functions/DefaultFunctionLibrary.java | 4 + .../core/AdvancedRandomNumberFunction.java | 144 ++++ .../functions/core/RandomPatternFunction.java | 60 ++ .../org/citrusframework/util/StringUtils.java | 27 + .../core/RandomDoubleFunctionTest.java | 246 ++++++ .../core/RandomPatternFunctionTest.java | 75 ++ .../citrusframework/util/StringUtilsTest.java | 117 ++- .../http/message/HttpMessageUtils.java | 39 +- .../http/message/HttpMessageUtilsTest.java | 126 ++- 51 files changed, 3885 insertions(+), 839 deletions(-) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java rename connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/{ => util}/OpenApiUtils.java (79%) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java create mode 100644 core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java create mode 100644 core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java create mode 100644 core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java create mode 100644 core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java new file mode 100644 index 0000000000..d6802ed996 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java @@ -0,0 +1,41 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +public abstract class OpenApiConstants { + + public static final String TYPE_ARRAY = "array"; + public static final String TYPE_BOOLEAN = "boolean"; + public static final String TYPE_INTEGER = "integer"; + public static final String TYPE_NUMBER = "number"; + public static final String TYPE_OBJECT = "object"; + public static final String TYPE_STRING = "string"; + + public static final String FORMAT_INT32 = "int32"; + public static final String FORMAT_INT64 = "int64"; + public static final String FORMAT_FLOAT = "float"; + public static final String FORMAT_DOUBLE = "double"; + public static final String FORMAT_DATE = "date"; + public static final String FORMAT_DATE_TIME = "date-time"; + public static final String FORMAT_UUID = "uuid"; + + /** + * Prevent instantiation. + */ + private OpenApiConstants() { + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java index 083da342ca..20f845d604 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java @@ -94,8 +94,8 @@ public void setResponseValidationEnabled(boolean responseValidationEnabled) { public void addRepository(Resource openApiResource) { OpenApiSpecification openApiSpecification = OpenApiSpecification.from(openApiResource); determineResourceAlias(openApiResource).ifPresent(openApiSpecification::addAlias); - openApiSpecification.setRequestValidationEnabled(requestValidationEnabled); - openApiSpecification.setResponseValidationEnabled(responseValidationEnabled); + openApiSpecification.setApiRequestValidationEnabled(requestValidationEnabled); + openApiSpecification.setApiResponseValidationEnabled(responseValidationEnabled); openApiSpecification.setRootContextPath(rootContextPath); this.openApiSpecifications.add(openApiSpecification); diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java index ea99928985..02441f1115 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java @@ -1,3 +1,19 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi; import static java.lang.Boolean.parseBoolean; @@ -35,7 +51,7 @@ public static boolean isValidateOptionalFieldsGlobally() { System.getenv(VALIDATE_OPTIONAL_FIELDS_ENV) : "true")); } - public static boolean isRequestValidationEnabledlobally() { + public static boolean isRequestValidationEnabledGlobally() { return parseBoolean(System.getProperty( REQUEST_VALIDATION_ENABLED_PROPERTY, System.getenv(REQUEST_VALIDATION_ENABLED_ENV) != null ? System.getenv(REQUEST_VALIDATION_ENABLED_ENV) : "true")); diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java index cd70694f74..f27f1bed48 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java @@ -16,24 +16,14 @@ package org.citrusframework.openapi; -import com.atlassian.oai.validator.OpenApiInteractionValidator; -import com.atlassian.oai.validator.OpenApiInteractionValidator.Builder; +import static org.citrusframework.openapi.OpenApiSettings.isGenerateOptionalFieldsGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isValidateOptionalFieldsGlobally; + import io.apicurio.datamodels.core.models.common.Info; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; -import org.citrusframework.context.TestContext; -import org.citrusframework.exceptions.CitrusRuntimeException; -import org.citrusframework.http.client.HttpClient; -import org.citrusframework.openapi.model.OasModelHelper; -import org.citrusframework.openapi.model.OperationPathAdapter; -import org.citrusframework.openapi.validation.OpenApiRequestValidator; -import org.citrusframework.openapi.validation.OpenApiResponseValidator; -import org.citrusframework.spi.Resource; -import org.citrusframework.spi.Resources; -import org.citrusframework.util.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.net.MalformedURLException; import java.net.URI; import java.net.URL; @@ -45,11 +35,19 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; - -import static org.citrusframework.openapi.OpenApiSettings.isGenerateOptionalFieldsGlobally; -import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; -import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; -import static org.citrusframework.openapi.OpenApiSettings.isValidateOptionalFieldsGlobally; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.http.client.HttpClient; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.openapi.util.OpenApiUtils; +import org.citrusframework.openapi.validation.SwaggerOpenApiValidationContext; +import org.citrusframework.openapi.validation.SwaggerOpenApiValidationContextLoader; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources; +import org.citrusframework.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * OpenApi specification resolves URL or local file resources to a specification document. @@ -97,33 +95,41 @@ public class OpenApiSpecification { private OasDocument openApiDoc; + private SwaggerOpenApiValidationContext swaggerOpenApiValidationContext; + private boolean generateOptionalFields = isGenerateOptionalFieldsGlobally(); private boolean validateOptionalFields = isValidateOptionalFieldsGlobally(); - private boolean requestValidationEnabled = isRequestValidationEnabledlobally(); + /** + * Flag to indicate, whether request validation is enabled on api level. Api level overrules global + * level and may be overruled by request level. + */ + private boolean apiRequestValidationEnabled = isRequestValidationEnabledGlobally(); - private boolean responseValidationEnabled = isResponseValidationEnabledGlobally(); + /** + * Flag to indicate, whether response validation is enabled on api level. Api level overrules global + * level and may be overruled by request level. + */ + private boolean apiResponseValidationEnabled = isResponseValidationEnabledGlobally(); private final Set aliases = Collections.synchronizedSet(new HashSet<>()); /** - * Maps the identifier (id) of an operation to OperationPathAdapters. Two different keys may be used for each operation. - * Refer to {@link org.citrusframework.openapi.OpenApiSpecification#storeOperationPathAdapter} for more details. + * Maps the identifier (id) of an operation to OperationPathAdapters. Two different keys may be + * used for each operation. Refer to + * {@link org.citrusframework.openapi.OpenApiSpecification#storeOperationPathAdapter} for more + * details. */ private final Map operationIdToOperationPathAdapter = new ConcurrentHashMap<>(); /** - * Stores the unique identifier (uniqueId) of an operation, derived from its HTTP method and path. - * This identifier can always be determined and is therefore safe to use, even for operations without - * an optional operationId defined. + * Stores the unique identifier (uniqueId) of an operation, derived from its HTTP method and + * path. This identifier can always be determined and is therefore safe to use, even for + * operations without an optional operationId defined. */ private final Map operationToUniqueId = new ConcurrentHashMap<>(); - private OpenApiRequestValidator openApiRequestValidator; - - private OpenApiResponseValidator openApiResponseValidator; - public static OpenApiSpecification from(String specUrl) { OpenApiSpecification specification = new OpenApiSpecification(); specification.setSpecUrl(specUrl); @@ -134,21 +140,19 @@ public static OpenApiSpecification from(String specUrl) { public static OpenApiSpecification from(URL specUrl) { OpenApiSpecification specification = new OpenApiSpecification(); OasDocument openApiDoc; - OpenApiInteractionValidator validator; + SwaggerOpenApiValidationContext swaggerOpenApiValidationContext; if (specUrl.getProtocol().startsWith(HTTPS)) { openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specUrl); - validator = new OpenApiInteractionValidator.Builder().withInlineApiSpecification( - OpenApiResourceLoader.rawFromSecuredWebResource(specUrl)).build(); + swaggerOpenApiValidationContext = SwaggerOpenApiValidationContextLoader.fromSecuredWebResource(specUrl); } else { openApiDoc = OpenApiResourceLoader.fromWebResource(specUrl); - validator = new OpenApiInteractionValidator.Builder().withInlineApiSpecification( - OpenApiResourceLoader.rawFromWebResource(specUrl)).build(); + swaggerOpenApiValidationContext = SwaggerOpenApiValidationContextLoader.fromWebResource(specUrl); } specification.setSpecUrl(specUrl.toString()); specification.initPathLookups(); specification.setOpenApiDoc(openApiDoc); - specification.setValidator(validator); + specification.setSwaggerOpenApiValidationContext(swaggerOpenApiValidationContext); specification.setRequestUrl( String.format("%s://%s%s%s", specUrl.getProtocol(), specUrl.getHost(), specUrl.getPort() > 0 ? ":" + specUrl.getPort() : "", @@ -160,11 +164,9 @@ public static OpenApiSpecification from(URL specUrl) { public static OpenApiSpecification from(Resource resource) { OpenApiSpecification specification = new OpenApiSpecification(); OasDocument openApiDoc = OpenApiResourceLoader.fromFile(resource); - OpenApiInteractionValidator validator = new Builder().withInlineApiSpecification( - OpenApiResourceLoader.rawFromFile(resource)).build(); specification.setOpenApiDoc(openApiDoc); - specification.setValidator(validator); + specification.setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromFile(resource)); String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) .orElse(Collections.singletonList(HTTP)) @@ -215,12 +217,11 @@ public synchronized OasDocument getOpenApiDoc(TestContext context) { if (resolvedSpecUrl.startsWith(HTTPS)) { initApiDoc( () -> OpenApiResourceLoader.fromSecuredWebResource(specWebResource)); - setValidator(new OpenApiInteractionValidator.Builder().withInlineApiSpecification( - OpenApiResourceLoader.rawFromSecuredWebResource(specWebResource)).build()); + + setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromSecuredWebResource(specWebResource)); } else { initApiDoc(() -> OpenApiResourceLoader.fromWebResource(specWebResource)); - setValidator(new OpenApiInteractionValidator.Builder().withInlineApiSpecification( - OpenApiResourceLoader.rawFromWebResource(specWebResource)).build()); + setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromWebResource(specWebResource)); } if (requestUrl == null) { @@ -234,8 +235,7 @@ public synchronized OasDocument getOpenApiDoc(TestContext context) { Resource resource = Resources.create(resolvedSpecUrl); initApiDoc( () -> OpenApiResourceLoader.fromFile(resource)); - setValidator(new OpenApiInteractionValidator.Builder().withInlineApiSpecification( - OpenApiResourceLoader.rawFromFile(resource)).build()); + setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromFile(resource)); if (requestUrl == null) { String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) @@ -255,6 +255,11 @@ public synchronized OasDocument getOpenApiDoc(TestContext context) { return openApiDoc; } + public SwaggerOpenApiValidationContext getSwaggerOpenApiValidationContext() { + return swaggerOpenApiValidationContext; + } + + // provided for testing URL toSpecUrl(String resolvedSpecUrl) { try { @@ -269,12 +274,10 @@ void setOpenApiDoc(OasDocument openApiDoc) { initApiDoc(() -> openApiDoc); } - private void setValidator(OpenApiInteractionValidator openApiInteractionValidator) { - openApiRequestValidator = new OpenApiRequestValidator(openApiInteractionValidator); - openApiRequestValidator.setEnabled(requestValidationEnabled); - - openApiResponseValidator = new OpenApiResponseValidator(openApiInteractionValidator); - openApiRequestValidator.setEnabled(responseValidationEnabled); + private void setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContext swaggerOpenApiValidationContext) { + this.swaggerOpenApiValidationContext = swaggerOpenApiValidationContext; + this.swaggerOpenApiValidationContext.setResponseValidationEnabled(apiResponseValidationEnabled); + this.swaggerOpenApiValidationContext.setRequestValidationEnabled(apiRequestValidationEnabled); } private void initApiDoc(Supplier openApiDocSupplier) { @@ -306,12 +309,14 @@ private void initPathLookups() { } /** - * Stores an {@link OperationPathAdapter} in {@link org.citrusframework.openapi.OpenApiSpecification#operationIdToOperationPathAdapter}. - * The adapter is stored using two keys: the operationId (optional) and the full path of the operation, including the method. - * The full path is always determinable and thus can always be safely used. + * Stores an {@link OperationPathAdapter} in + * {@link org.citrusframework.openapi.OpenApiSpecification#operationIdToOperationPathAdapter}. + * The adapter is stored using two keys: the operationId (optional) and the full path of the + * operation, including the method. The full path is always determinable and thus can always be + * safely used. * * @param operation The {@link OperationPathAdapter} to store. - * @param path The full path of the operation, including the method. + * @param path The full path of the operation, including the method. */ private void storeOperationPathAdapter(OasOperation operation, String path) { @@ -319,9 +324,10 @@ private void storeOperationPathAdapter(OasOperation operation, String path) { String fullOperationPath = StringUtils.appendSegmentToUrlPath(basePath, path); OperationPathAdapter operationPathAdapter = new OperationPathAdapter(path, rootContextPath, - StringUtils.appendSegmentToUrlPath(rootContextPath, path), operation); + StringUtils.appendSegmentToUrlPath(rootContextPath, path), operation); - String uniqueOperationId = OpenApiUtils.createFullPathOperationIdentifier(fullOperationPath, operation); + String uniqueOperationId = OpenApiUtils.createFullPathOperationIdentifier(fullOperationPath, + operation); operationToUniqueId.put(operation, uniqueOperationId); operationIdToOperationPathAdapter.put(uniqueOperationId, operationPathAdapter); @@ -358,25 +364,25 @@ public void setRequestUrl(String requestUrl) { this.requestUrl = requestUrl; } - public boolean isRequestValidationEnabled() { - return requestValidationEnabled; + public boolean isApiRequestValidationEnabled() { + return apiRequestValidationEnabled; } - public void setRequestValidationEnabled(boolean enabled) { - this.requestValidationEnabled = enabled; - if (this.openApiRequestValidator != null) { - this.openApiRequestValidator.setEnabled(enabled); + public void setApiRequestValidationEnabled(boolean enabled) { + this.apiRequestValidationEnabled = enabled; + if (this.swaggerOpenApiValidationContext != null) { + this.swaggerOpenApiValidationContext.setRequestValidationEnabled(enabled); } } - public boolean isResponseValidationEnabled() { - return responseValidationEnabled; + public boolean isApiResponseValidationEnabled() { + return apiResponseValidationEnabled; } - public void setResponseValidationEnabled(boolean enabled) { - this.responseValidationEnabled = enabled; - if (this.openApiResponseValidator != null) { - this.openApiResponseValidator.setEnabled(enabled); + public void setApiResponseValidationEnabled(boolean enabled) { + this.apiResponseValidationEnabled = enabled; + if (this.swaggerOpenApiValidationContext != null) { + this.swaggerOpenApiValidationContext.setResponseValidationEnabled(enabled); } } @@ -449,14 +455,6 @@ public Optional getOperation(String operationId, TestConte return Optional.ofNullable(operationIdToOperationPathAdapter.get(operationId)); } - public Optional getRequestValidator() { - return Optional.ofNullable(openApiRequestValidator); - } - - public Optional getResponseValidator() { - return Optional.ofNullable(openApiResponseValidator); - } - public OpenApiSpecification withRootContext(String rootContextPath) { setRootContextPath(rootContextPath); return this; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java index d98ed5d5c9..6b5c7b2110 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java @@ -16,74 +16,125 @@ package org.citrusframework.openapi; +import static java.lang.Boolean.TRUE; +import static java.lang.String.format; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_INTEGER; +import static org.citrusframework.util.StringUtils.hasText; +import static org.citrusframework.util.StringUtils.quote; +import static org.springframework.util.CollectionUtils.isEmpty; + import io.apicurio.datamodels.openapi.models.OasSchema; -import jakarta.annotation.Nullable; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; -import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.openapi.model.OasModelHelper; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; - -import java.util.Map; -import java.util.stream.Collectors; +import org.citrusframework.openapi.util.OpenApiUtils; +import org.citrusframework.openapi.util.RandomModelBuilder; /** - * Generates proper payloads and validation expressions based on Open API specification rules. Creates outbound payloads - * with generated random test data according to specification and creates inbound payloads with proper validation expressions to - * enforce the specification rules. - * - * @author Christoph Deppisch + * Generates proper payloads expressions based on Open API specification rules. */ -public class OpenApiTestDataGenerator { +public abstract class OpenApiTestDataGenerator { + + public static final BigDecimal THOUSAND = new BigDecimal(1000); + public static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + public static final BigDecimal MINUS_THOUSAND = new BigDecimal(-1000); + + private OpenApiTestDataGenerator() { + // Static access only + } + + private static final Map SPECIAL_FORMATS = Map.of( + "email", "[a-z]{5,15}\\.?[a-z]{5,15}\\@[a-z]{5,15}\\.[a-z]{2}", + "uri", + "((http|https)://[a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+(/[a-zA-Z0-9-]+){1,6})|(file:///[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+){1,6})", + "hostname", + "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])", + "ipv4", + "(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)", + "ipv6", + "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"); + + /** + * Creates payload from schema for outbound message. + */ + public static String createOutboundPayload(OasSchema schema, + OpenApiSpecification specification) { + return createOutboundPayload(schema, + OasModelHelper.getSchemaDefinitions(specification.getOpenApiDoc(null)), specification); + } /** * Creates payload from schema for outbound message. */ public static String createOutboundPayload(OasSchema schema, Map definitions, - OpenApiSpecification specification) { + OpenApiSpecification specification) { + return createOutboundPayload(schema, definitions, specification, new HashSet<>()); + } + + /** + * Creates payload from schema for outbound message. + */ + private static String createOutboundPayload(OasSchema schema, + Map definitions, + OpenApiSpecification specification, Set visitedRefSchemas) { + RandomModelBuilder randomModelBuilder = new RandomModelBuilder(); + createOutboundPayloadAsMap(randomModelBuilder, schema, definitions, specification, + visitedRefSchemas); + return randomModelBuilder.toString(); + } + + private static void createOutboundPayloadAsMap(RandomModelBuilder randomModelBuilder, + OasSchema schema, + Map definitions, + OpenApiSpecification specification, Set visitedRefSchemas) { + + if (hasText(schema.$ref) && visitedRefSchemas.contains(schema)) { + // Avoid recursion + return; + } + if (OasModelHelper.isReferenceType(schema)) { OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - return createOutboundPayload(resolved, definitions, specification); + createOutboundPayloadAsMap(randomModelBuilder, resolved, definitions, specification, + visitedRefSchemas); + return; } - StringBuilder payload = new StringBuilder(); - if (OasModelHelper.isObjectType(schema)) { - payload.append("{"); - - if (schema.properties != null) { - for (Map.Entry entry : schema.properties.entrySet()) { - if (specification.isGenerateOptionalFields() || isRequired(schema, entry.getKey())) { - payload.append("\"") - .append(entry.getKey()) - .append("\": ") - .append(createRandomValueExpression(entry.getValue(), definitions, true, specification)) - .append(","); - } - } - } - - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } else if (OasModelHelper.isArrayType(schema)) { - payload.append("["); - payload.append(createRandomValueExpression((OasSchema) schema.items, definitions, true, specification)); - payload.append("]"); - } else { - payload.append(createRandomValueExpression(schema, definitions, true, specification)); + if (OasModelHelper.isCompositeSchema(schema)) { + createComposedSchema(randomModelBuilder, schema, true, specification, + visitedRefSchemas); + return; } - return payload.toString(); + switch (schema.type) { + case OpenApiConstants.TYPE_OBJECT -> + createRandomObjectSchemeMap(randomModelBuilder, schema, specification, + visitedRefSchemas); + case OpenApiConstants.TYPE_ARRAY -> + createRandomArrayValueMap(randomModelBuilder, schema, specification, + visitedRefSchemas); + case OpenApiConstants.TYPE_STRING, TYPE_INTEGER, OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_BOOLEAN -> + createRandomValueExpressionMap(randomModelBuilder, schema, true); + default -> randomModelBuilder.appendSimple("\"\""); + } } /** * Use test variable with given name if present or create value from schema with random values */ - public static String createRandomValueExpression(String name, OasSchema schema, Map definitions, - boolean quotes, OpenApiSpecification specification, TestContext context) { + public static String createRandomValueExpression(String name, OasSchema schema, + Map definitions, + boolean quotes, OpenApiSpecification specification, TestContext context) { if (context.getVariables().containsKey(name)) { return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX; } @@ -91,20 +142,12 @@ public static String createRandomValueExpression(String name, OasSchema schema, return createRandomValueExpression(schema, definitions, quotes, specification); } - public static T createRawRandomValueExpression(String name, OasSchema schema, Map definitions, - boolean quotes, OpenApiSpecification specification, TestContext context) { - if (context.getVariables().containsKey(name)) { - return (T)context.getVariables().get(CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX); - } - - return createRawRandomValueExpression(schema, definitions, quotes, specification, context); - } - /** * Create payload from schema with random values. */ - public static String createRandomValueExpression(OasSchema schema, Map definitions, boolean quotes, - OpenApiSpecification specification) { + public static String createRandomValueExpression(OasSchema schema, + Map definitions, boolean quotes, + OpenApiSpecification specification) { if (OasModelHelper.isReferenceType(schema)) { OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); return createRandomValueExpression(resolved, definitions, quotes, specification); @@ -113,31 +156,44 @@ public static String createRandomValueExpression(OasSchema schema, Map "'" + value + "'").collect(Collectors.joining(","))).append(")"); - } else if (schema.format != null && schema.format.equals("uuid")) { + } else if (!isEmpty(schema.enum_)) { + payload.append("citrus:randomEnumValue(").append( + schema.enum_.stream().map(value -> "'" + value + "'") + .collect(Collectors.joining(","))).append(")"); + } else if (OpenApiConstants.FORMAT_UUID.equals(schema.format)) { payload.append("citrus:randomUUID()"); } else { - payload.append("citrus:randomString(").append(schema.maxLength != null && schema.maxLength.intValue() > 0 ? schema.maxLength : (schema.minLength != null && schema.minLength.intValue() > 0 ? schema.minLength : 10)).append(")"); + if (schema.format != null && SPECIAL_FORMATS.containsValue(schema.format)) { + payload.append("citrus:randomValue('") + .append(SPECIAL_FORMATS.get(schema.format)).append("')"); + } else { + int length = 10; + if (schema.maxLength != null && schema.maxLength.intValue() > 0) { + length = schema.maxLength.intValue(); + } else if (schema.minLength != null && schema.minLength.intValue() > 0) { + length = schema.minLength.intValue(); + } + + payload.append("citrus:randomString(").append(length).append(")"); + } } if (quotes) { payload.append("\""); } - } else if ("integer".equals(schema.type) || "number".equals(schema.type)) { + } else if (OpenApiUtils.isAnyNumberScheme(schema)) { payload.append("citrus:randomNumber(8)"); - } else if ("boolean".equals(schema.type)) { + } else if (OpenApiConstants.TYPE_BOOLEAN.equals(schema.type)) { payload.append("citrus:randomEnumValue('true', 'false')"); } else if (quotes) { payload.append("\"\""); @@ -146,69 +202,33 @@ public static String createRandomValueExpression(OasSchema schema, Map T createRawRandomValueExpression(OasSchema schema, Map definitions, boolean quotes, + public static T createRawRandomValueExpression(OasSchema schema, + Map definitions, boolean quotes, OpenApiSpecification specification, TestContext context) { if (OasModelHelper.isReferenceType(schema)) { OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - return createRawRandomValueExpression(resolved, definitions, quotes, specification, context); + return createRawRandomValueExpression(resolved, definitions, quotes, specification, + context); } StringBuilder payload = new StringBuilder(); - if ("string".equals(schema.type) || OasModelHelper.isObjectType(schema) || OasModelHelper.isArrayType(schema)) { - return (T)createRandomValueExpression(schema, definitions, quotes, specification); - } else if ("number".equals(schema.type)) { - return (T)Double.valueOf(context.replaceDynamicContentInString("citrus:randomNumber(8,2)")); + if (OpenApiConstants.TYPE_STRING.equals(schema.type) || OasModelHelper.isObjectType(schema) + || OasModelHelper.isArrayType(schema)) { + return (T) createRandomValueExpression(schema, definitions, quotes, specification); + } else if (OpenApiConstants.TYPE_NUMBER.equals(schema.type)) { + return (T) Double.valueOf( + context.replaceDynamicContentInString("citrus:randomNumber(8,2)")); } else if ("integer".equals(schema.type)) { - return (T)Double.valueOf(context.replaceDynamicContentInString("citrus:randomNumber(8)")); + return (T) Double.valueOf( + context.replaceDynamicContentInString("citrus:randomNumber(8)")); } else if ("boolean".equals(schema.type)) { - return (T)Boolean.valueOf(context.replaceDynamicContentInString("citrus:randomEnumValue('true', 'false')")); + return (T) Boolean.valueOf( + context.replaceDynamicContentInString("citrus:randomEnumValue('true', 'false')")); } else if (quotes) { payload.append("\"\""); } - return (T)payload.toString(); - } - - /** - * Creates control payload from schema for validation. - */ - public static String createInboundPayload(OasSchema schema, Map definitions, - OpenApiSpecification specification) { - if (OasModelHelper.isReferenceType(schema)) { - OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - return createInboundPayload(resolved, definitions, specification); - } - - StringBuilder payload = new StringBuilder(); - if (OasModelHelper.isObjectType(schema)) { - payload.append("{"); - - if (schema.properties != null) { - for (Map.Entry entry : schema.properties.entrySet()) { - if (specification.isValidateOptionalFields() || isRequired(schema, entry.getKey())) { - payload.append("\"") - .append(entry.getKey()) - .append("\": ") - .append(createValidationExpression(entry.getValue(), definitions, true, specification)) - .append(","); - } - } - } - - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } - - payload.append("}"); - } else if (OasModelHelper.isArrayType(schema)) { - payload.append("["); - payload.append(createValidationExpression((OasSchema) schema.items, definitions, true, specification)); - payload.append("]"); - } else { - payload.append(createValidationExpression(schema, definitions, false, specification)); - } - - return payload.toString(); + return (T) payload.toString(); } /** @@ -223,182 +243,403 @@ private static boolean isRequired(OasSchema schema, String field) { } /** - * Use test variable with given name if present or create validation expression using functions according to schema type and format. + * Use test variable with given name (if present) or create random value expression using + * functions according to schema type and format. */ - public static String createValidationExpression(String name, OasSchema schema, Map definitions, - boolean quotes, OpenApiSpecification specification, - TestContext context) { + public static String createRandomValueExpression(String name, OasSchema schema, + TestContext context) { if (context.getVariables().containsKey(name)) { return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX; } - return createValidationExpression(schema, definitions, quotes, specification); + RandomModelBuilder randomModelBuilder = new RandomModelBuilder(); + createRandomValueExpressionMap(randomModelBuilder, schema, false); + return randomModelBuilder.toString(); + } + + public static String createRandomValueExpression(OasSchema schema, boolean quotes) { + RandomModelBuilder randomModelBuilder = new RandomModelBuilder(); + createRandomValueExpressionMap(randomModelBuilder, schema, quotes); + return randomModelBuilder.toString(); } /** - * Create validation expression using functions according to schema type and format. + * Create random value expression using functions according to schema type and format. */ - public static String createValidationExpression(OasSchema schema, Map definitions, boolean quotes, - OpenApiSpecification specification) { - if (OasModelHelper.isReferenceType(schema)) { - OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - return createValidationExpression(resolved, definitions, quotes, specification); - } + private static void createRandomValueExpressionMap(RandomModelBuilder randomModelBuilder, + OasSchema schema, boolean quotes) { - StringBuilder payload = new StringBuilder(); - if (OasModelHelper.isObjectType(schema)) { - payload.append("{"); - - if (schema.properties != null) { - for (Map.Entry entry : schema.properties.entrySet()) { - if (specification.isValidateOptionalFields() || isRequired(schema, entry.getKey())) { - payload.append("\"") - .append(entry.getKey()) - .append("\": ") - .append(createValidationExpression(entry.getValue(), definitions, quotes, specification)) - .append(","); + switch (schema.type) { + case OpenApiConstants.TYPE_STRING -> { + if (OpenApiConstants.FORMAT_DATE.equals(schema.format)) { + randomModelBuilder.appendSimple( + quote("citrus:currentDate('yyyy-MM-dd')", quotes)); + } else if (OpenApiConstants.FORMAT_DATE_TIME.equals(schema.format)) { + randomModelBuilder.appendSimple( + quote("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')", quotes)); + } else if (hasText(schema.pattern)) { + randomModelBuilder.appendSimple( + quote("citrus:randomValue('" + schema.pattern + "')", quotes)); + } else if (!isEmpty(schema.enum_)) { + randomModelBuilder.appendSimple( + quote("citrus:randomEnumValue(" + (java.lang.String.join(",", schema.enum_)) + + ")", quotes)); + } else if (OpenApiConstants.FORMAT_UUID.equals(schema.format)) { + randomModelBuilder.appendSimple(quote("citrus:randomUUID()", quotes)); + } else { + + if (schema.format != null && SPECIAL_FORMATS.containsKey(schema.format)) { + randomModelBuilder.appendSimple(quote( + "citrus:randomValue('" + SPECIAL_FORMATS.get(schema.format) + "')", + quotes)); + } else { + long minLength = + schema.minLength != null && schema.minLength.longValue() > 0 + ? schema.minLength.longValue() : 10L; + long maxLength = + schema.maxLength != null && schema.maxLength.longValue() > 0 + ? schema.maxLength.longValue() : 10L; + long length = ThreadLocalRandom.current() + .nextLong(minLength, maxLength + 1); + randomModelBuilder.appendSimple( + quote("citrus:randomString(%s)".formatted(length), quotes)); } } } + case OpenApiConstants.TYPE_NUMBER, TYPE_INTEGER -> + // No quotes for numbers + randomModelBuilder.appendSimple(createRandomNumber(schema)); + case OpenApiConstants.TYPE_BOOLEAN -> + // No quotes for boolean + randomModelBuilder.appendSimple("citrus:randomEnumValue('true', 'false')"); + default -> randomModelBuilder.appendSimple(""); + } + } - if (payload.toString().endsWith(",")) { - payload.replace(payload.length() - 1, payload.length(), ""); - } + private static String createRandomNumber(OasSchema schema) { + Number multipleOf = schema.multipleOf; - payload.append("}"); - } else { - if (quotes) { - payload.append("\""); - } + boolean exclusiveMaximum = TRUE.equals(schema.exclusiveMaximum); + boolean exclusiveMinimum = TRUE.equals(schema.exclusiveMinimum); - payload.append(createValidationExpression(schema)); + BigDecimal[] bounds = determineBounds(schema); - if (quotes) { - payload.append("\""); + BigDecimal minimum = bounds[0]; + BigDecimal maximum = bounds[1]; + + if (multipleOf != null) { + minimum = exclusiveMinimum ? incrementToExclude(minimum) : minimum; + maximum = exclusiveMaximum ? decrementToExclude(maximum) : maximum; + return createMultipleOf(minimum, maximum, new BigDecimal(multipleOf.toString())); + } + + return format( + "citrus:randomNumberGenerator('%d', '%s', '%s', '%s', '%s')", + determineDecimalPlaces(schema, minimum, maximum), + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum + ); + } + + /** + * Determines the number of decimal places to use based on the given schema and minimum/maximum values. + * For integer types, it returns 0. For other types, it returns the maximum number of decimal places + * found between the minimum and maximum values, with a minimum of 2 decimal places. + */ + private static int determineDecimalPlaces(OasSchema schema, BigDecimal minimum, + BigDecimal maximum) { + if (TYPE_INTEGER.equals(schema.type)) { + return 0; + } else { + return + Math.max(2, Math.max(findLeastSignificantDecimalPlace(minimum), + findLeastSignificantDecimalPlace(maximum))); + } + } + + /** + * Determine some reasonable bounds for a random number + */ + private static BigDecimal[] determineBounds(OasSchema schema) { + Number maximum = schema.maximum; + Number minimum = schema.minimum; + Number multipleOf = schema.multipleOf; + + BigDecimal bdMinimum; + BigDecimal bdMaximum; + if (minimum == null && maximum == null) { + bdMinimum = MINUS_THOUSAND; + bdMaximum = THOUSAND; + } else if (minimum == null) { + // Determine min relative to max + bdMaximum = new BigDecimal(maximum.toString()); + + if (multipleOf != null) { + bdMinimum = bdMaximum.subtract(new BigDecimal(multipleOf.toString()).abs().multiply( + HUNDRED)); + } else { + bdMinimum = bdMaximum.subtract(bdMaximum.multiply(BigDecimal.valueOf(2)).max( + THOUSAND)); } + } else if (maximum == null) { + // Determine max relative to min + bdMinimum = new BigDecimal(minimum.toString()); + if (multipleOf != null) { + bdMaximum = bdMinimum.add(new BigDecimal(multipleOf.toString()).abs().multiply( + HUNDRED)); + } else { + bdMaximum = bdMinimum.add(bdMinimum.multiply(BigDecimal.valueOf(2)).max(THOUSAND)); + } + } else { + bdMaximum = new BigDecimal(maximum.toString()); + bdMinimum = new BigDecimal(minimum.toString()); } - return payload.toString(); + return new BigDecimal[]{bdMinimum, bdMaximum}; } /** - * Create validation expression using functions according to schema type and format. + * Create a random schema value + * + * @param schema the type to create + * @param visitedRefSchemas the schemas already created during descent, used to avoid recursion */ - private static String createValidationExpression(OasSchema schema) { + private static void createRandomValue(RandomModelBuilder randomModelBuilder, OasSchema schema, + boolean quotes, + OpenApiSpecification specification, Set visitedRefSchemas) { + if (hasText(schema.$ref) && visitedRefSchemas.contains(schema)) { + // Avoid recursion + return; + } + + if (OasModelHelper.isReferenceType(schema)) { + OasSchema resolved = OasModelHelper.getSchemaDefinitions( + specification.getOpenApiDoc(null)) + .get(OasModelHelper.getReferenceName(schema.$ref)); + createRandomValue(randomModelBuilder, resolved, quotes, specification, + visitedRefSchemas); + return; + } if (OasModelHelper.isCompositeSchema(schema)) { - /* - * Currently these schemas are not supported by validation expressions. They are supported - * by {@link org.citrusframework.openapi.validation.OpenApiValidator} though. - */ - return "@ignore@"; + createComposedSchema(randomModelBuilder, schema, quotes, specification, + visitedRefSchemas); + return; } switch (schema.type) { - case "string": - if (schema.format != null && schema.format.equals("date")) { - return "@matchesDatePattern('yyyy-MM-dd')@"; - } else if (schema.format != null && schema.format.equals("date-time")) { - return "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ssZ')@"; - } else if (StringUtils.hasText(schema.pattern)) { - return String.format("@matches(%s)@", schema.pattern); - } else if (!CollectionUtils.isEmpty(schema.enum_)) { - return String.format("@matches(%s)@", String.join("|", schema.enum_)); + case OpenApiConstants.TYPE_OBJECT -> + createRandomObjectSchemeMap(randomModelBuilder, schema, specification, + visitedRefSchemas); + case OpenApiConstants.TYPE_ARRAY -> + createRandomArrayValueMap(randomModelBuilder, schema, specification, + visitedRefSchemas); + case OpenApiConstants.TYPE_STRING, TYPE_INTEGER, OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_BOOLEAN -> + createRandomValueExpressionMap(randomModelBuilder, schema, quotes); + default -> { + if (quotes) { + randomModelBuilder.appendSimple("\"\""); } else { - return "@notEmpty()@"; + randomModelBuilder.appendSimple(""); } - case "number": - case "integer": - return "@isNumber()@"; - case "boolean": - return "@matches(true|false)@"; - default: - return "@ignore@"; + } } } - /** - * Use test variable with given name (if present) or create random value expression using functions according to - * schema type and format. - */ - public static String createRandomValueExpression(String name, OasSchema schema, TestContext context) { - if (context.getVariables().containsKey(name)) { - return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX; + private static void createRandomObjectSchemeMap(RandomModelBuilder randomModelBuilder, + OasSchema objectSchema, + OpenApiSpecification specification, Set visitedRefSchemas) { + + randomModelBuilder.object(() -> { + if (objectSchema.properties != null) { + for (Map.Entry entry : objectSchema.properties.entrySet()) { + if (specification.isGenerateOptionalFields() || isRequired(objectSchema, + entry.getKey())) { + randomModelBuilder.property(entry.getKey(), () -> + createRandomValue(randomModelBuilder, entry.getValue(), true, + specification, + visitedRefSchemas)); + } + } + } + }); + } + + private static void createComposedSchema(RandomModelBuilder randomModelBuilder, + OasSchema schema, boolean quotes, + OpenApiSpecification specification, Set visitedRefSchemas) { + + if (!isEmpty(schema.allOf)) { + createAllOff(randomModelBuilder, schema, quotes, specification, visitedRefSchemas); + } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.anyOf)) { + createAnyOf(randomModelBuilder, oas30Schema, quotes, specification, visitedRefSchemas); + } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.oneOf)) { + createOneOf(randomModelBuilder, oas30Schema.oneOf, quotes, specification, + visitedRefSchemas); } + } - return createRandomValueExpression(schema); + private static void createOneOf(RandomModelBuilder randomModelBuilder, List schemas, + boolean quotes, + OpenApiSpecification specification, Set visitedRefSchemas) { + int schemaIndex = ThreadLocalRandom.current().nextInt(schemas.size()); + randomModelBuilder.object(() -> + createRandomValue(randomModelBuilder, schemas.get(schemaIndex), quotes, specification, + visitedRefSchemas)); } - /** - * Create random value expression using functions according to schema type and format. - */ - public static String createRandomValueExpression(OasSchema schema) { - switch (schema.type) { - case "string": - if (schema.format != null && schema.format.equals("date")) { - return "\"citrus:currentDate('yyyy-MM-dd')\""; - } else if (schema.format != null && schema.format.equals("date-time")) { - return "\"citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')\""; - } else if (StringUtils.hasText(schema.pattern)) { - return "\"citrus:randomValue(" + schema.pattern + ")\""; - } else if (!CollectionUtils.isEmpty(schema.enum_)) { - return "\"citrus:randomEnumValue(" + (String.join(",", schema.enum_)) + ")\""; - } else if (schema.format != null && schema.format.equals("uuid")){ - return "citrus:randomUUID()"; - } else { - return "citrus:randomString(10)"; + private static void createAnyOf(RandomModelBuilder randomModelBuilder, Oas30Schema schema, + boolean quotes, + OpenApiSpecification specification, Set visitedRefSchemas) { + + randomModelBuilder.object(() -> { + boolean anyAdded = false; + for (OasSchema oneSchema : schema.anyOf) { + if (ThreadLocalRandom.current().nextBoolean()) { + createRandomValue(randomModelBuilder, oneSchema, quotes, specification, + visitedRefSchemas); + anyAdded = true; } - case "number": - case "integer": - return "citrus:randomNumber(8)"; - case "boolean": - return "citrus:randomEnumValue('true', 'false')"; - default: - return ""; + } + + // Add at least one + if (!anyAdded) { + createOneOf(randomModelBuilder, schema.anyOf, quotes, specification, + visitedRefSchemas); + } + }); + } + + private static Map createAllOff(RandomModelBuilder randomModelBuilder, + OasSchema schema, boolean quotes, + OpenApiSpecification specification, Set visitedRefSchemas) { + Map allOf = new HashMap<>(); + + randomModelBuilder.object(() -> { + for (OasSchema oneSchema : schema.allOf) { + createRandomValue(randomModelBuilder, oneSchema, quotes, specification, + visitedRefSchemas); + } + }); + + return allOf; + } + + private static String createMultipleOf( + BigDecimal minimum, + BigDecimal maximum, + BigDecimal multipleOf + ) { + + BigDecimal lowestMultiple = lowestMultipleOf(minimum, multipleOf); + BigDecimal largestMultiple = largestMultipleOf(maximum, multipleOf); + + // Check if there are no valid multiples in the range + if (lowestMultiple.compareTo(largestMultiple) > 0) { + return null; + } + + BigDecimal range = largestMultiple.subtract(lowestMultiple) + .divide(multipleOf, RoundingMode.DOWN); + + // Don't go for incredible large numbers + if (range.compareTo(BigDecimal.valueOf(11)) > 0) { + range = BigDecimal.valueOf(10); + } + + long factor = 0; + if (range.compareTo(BigDecimal.ZERO) != 0) { + factor = ThreadLocalRandom.current().nextLong(1, range.longValue() + 1); } + BigDecimal randomMultiple = lowestMultiple.add( + multipleOf.multiply(BigDecimal.valueOf(factor))); + randomMultiple = randomMultiple.setScale(findLeastSignificantDecimalPlace(multipleOf), + RoundingMode.HALF_UP); + + return randomMultiple.toString(); } /** - * Create validation expression using regex according to schema type and format. + * Create a random array value. + * + * @param schema the type to create + * @param visitedRefSchemas the schemas already created during descent, used to avoid recursion */ - public static String createValidationRegex(String name, @Nullable OasSchema oasSchema) { - - if (oasSchema != null && (OasModelHelper.isReferenceType(oasSchema) || OasModelHelper.isObjectType(oasSchema))) { - throw new CitrusRuntimeException(String.format("Unable to create a validation regex for an reference of object schema '%s'!", name)); + @SuppressWarnings("rawtypes") + private static void createRandomArrayValueMap(RandomModelBuilder randomModelBuilder, + OasSchema schema, + OpenApiSpecification specification, Set visitedRefSchemas) { + Object items = schema.items; + + if (items instanceof OasSchema itemsSchema) { + createRandomArrayValueWithSchemaItem(randomModelBuilder, schema, itemsSchema, + specification, + visitedRefSchemas); + } else { + throw new UnsupportedOperationException( + "Random array creation for an array with items having different schema is currently not supported!"); } + } - return createValidationRegex(oasSchema); + private static void createRandomArrayValueWithSchemaItem(RandomModelBuilder randomModelBuilder, + OasSchema schema, + OasSchema itemsSchema, OpenApiSpecification specification, + Set visitedRefSchemas) { + Number minItems = schema.minItems; + minItems = minItems != null ? minItems : 1; + Number maxItems = schema.maxItems; + maxItems = maxItems != null ? maxItems : 9; + + int nItems = ThreadLocalRandom.current() + .nextInt(minItems.intValue(), maxItems.intValue() + 1); + + randomModelBuilder.array(() -> { + for (int i = 0; i < nItems; i++) { + createRandomValue(randomModelBuilder, itemsSchema, true, specification, + visitedRefSchemas); + } + }); } - public static String createValidationRegex(@Nullable OasSchema schema) { + static BigDecimal largestMultipleOf(BigDecimal highest, BigDecimal multipleOf) { + RoundingMode roundingMode = + highest.compareTo(BigDecimal.ZERO) < 0 ? RoundingMode.UP : RoundingMode.DOWN; + BigDecimal factor = highest.divide(multipleOf, 0, roundingMode); + return multipleOf.multiply(factor); + } - if (schema == null) { - return ""; - } + static BigDecimal lowestMultipleOf(BigDecimal lowest, BigDecimal multipleOf) { + RoundingMode roundingMode = + lowest.compareTo(BigDecimal.ZERO) < 0 ? RoundingMode.DOWN : RoundingMode.UP; + BigDecimal factor = lowest.divide(multipleOf, 0, roundingMode); + return multipleOf.multiply(factor); + } - switch (schema.type) { - case "string": - if (schema.format != null && schema.format.equals("date")) { - return "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])"; - } else if (schema.format != null && schema.format.equals("date-time")) { - return "\\d{4}-\\d{2}-\\d{2}T[01]\\d:[0-5]\\d:[0-5]\\dZ"; - } else if (StringUtils.hasText(schema.pattern)) { - return schema.pattern; - } else if (!CollectionUtils.isEmpty(schema.enum_)) { - return "(" + (String.join("|", schema.enum_)) + ")"; - } else if (schema.format != null && schema.format.equals("uuid")){ - return "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; - } else { - return ".*"; - } - case "number": - return "[0-9]+\\.?[0-9]*"; - case "integer": - return "[0-9]+"; - case "boolean": - return "(true|false)"; - default: - return ""; + static BigDecimal incrementToExclude(BigDecimal val) { + return val.add(determineIncrement(val)) + .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN); + } + + static BigDecimal decrementToExclude(BigDecimal val) { + return val.subtract(determineIncrement(val)) + .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN); + } + + static BigDecimal determineIncrement(BigDecimal number) { + return BigDecimal.valueOf(1.0d / (Math.pow(10d, findLeastSignificantDecimalPlace(number)))); + } + + static int findLeastSignificantDecimalPlace(BigDecimal number) { + number = number.stripTrailingZeros(); + + String[] parts = number.toPlainString().split("\\."); + + if (parts.length == 1) { + return 0; } + + return parts[1].length(); } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java new file mode 100644 index 0000000000..3f9e123679 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java @@ -0,0 +1,246 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.citrusframework.util.StringUtils.hasText; +import static org.springframework.util.CollectionUtils.isEmpty; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import jakarta.annotation.Nullable; +import java.util.Map; +import org.citrusframework.CitrusSettings; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.openapi.model.OasModelHelper; + +/** + * Generates proper payloads and validation expressions based on Open API specification rules. + * Creates outbound payloads with generated random test data according to specification and creates + * inbound payloads with proper validation expressions to enforce the specification rules. + * + * @author Christoph Deppisch + */ +public abstract class OpenApiTestValidationDataGenerator { + + private OpenApiTestValidationDataGenerator() { + // Static access only + } + + /** + * Creates control payload from schema for validation. + */ + public static String createInboundPayload(OasSchema schema, Map definitions, + OpenApiSpecification specification) { + if (OasModelHelper.isReferenceType(schema)) { + OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); + return createInboundPayload(resolved, definitions, specification); + } + + StringBuilder payload = new StringBuilder(); + if (OasModelHelper.isObjectType(schema)) { + payload.append("{"); + + if (schema.properties != null) { + for (Map.Entry entry : schema.properties.entrySet()) { + if (specification.isValidateOptionalFields() || isRequired(schema, + entry.getKey())) { + payload.append("\"") + .append(entry.getKey()) + .append("\": ") + .append(createValidationExpression(entry.getValue(), definitions, true, + specification)) + .append(","); + } + } + } + + if (payload.toString().endsWith(",")) { + payload.replace(payload.length() - 1, payload.length(), ""); + } + + payload.append("}"); + } else if (OasModelHelper.isArrayType(schema)) { + payload.append("["); + payload.append(createValidationExpression((OasSchema) schema.items, definitions, true, + specification)); + payload.append("]"); + } else { + payload.append(createValidationExpression(schema, definitions, false, specification)); + } + + return payload.toString(); + } + + /** + * Checks if given field name is in list of required fields for this schema. + */ + private static boolean isRequired(OasSchema schema, String field) { + if (schema.required == null) { + return true; + } + + return schema.required.contains(field); + } + + /** + * Use test variable with given name if present or create validation expression using functions + * according to schema type and format. + */ + public static String createValidationExpression(String name, OasSchema schema, + Map definitions, + boolean quotes, OpenApiSpecification specification, + TestContext context) { + if (context.getVariables().containsKey(name)) { + return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX; + } + + return createValidationExpression(schema, definitions, quotes, specification); + } + + /** + * Create validation expression using functions according to schema type and format. + */ + public static String createValidationExpression(OasSchema schema, + Map definitions, boolean quotes, + OpenApiSpecification specification) { + if (OasModelHelper.isReferenceType(schema)) { + OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); + return createValidationExpression(resolved, definitions, quotes, specification); + } + + StringBuilder payload = new StringBuilder(); + if (OasModelHelper.isObjectType(schema)) { + payload.append("{"); + + if (schema.properties != null) { + for (Map.Entry entry : schema.properties.entrySet()) { + if (specification.isValidateOptionalFields() || isRequired(schema, + entry.getKey())) { + payload.append("\"") + .append(entry.getKey()) + .append("\": ") + .append( + createValidationExpression(entry.getValue(), definitions, quotes, + specification)) + .append(","); + } + } + } + + if (payload.toString().endsWith(",")) { + payload.replace(payload.length() - 1, payload.length(), ""); + } + + payload.append("}"); + } else { + if (quotes) { + payload.append("\""); + } + + payload.append(createValidationExpression(schema)); + + if (quotes) { + payload.append("\""); + } + } + + return payload.toString(); + } + + /** + * Create validation expression using functions according to schema type and format. + */ + private static String createValidationExpression(OasSchema schema) { + + if (OasModelHelper.isCompositeSchema(schema)) { + /* + * Currently these schemas are not supported by validation expressions. They are supported + * by {@link org.citrusframework.openapi.validation.OpenApiValidator} though. + */ + return "@ignore@"; + } + + switch (schema.type) { + case "string" : + if (schema.format != null && schema.format.equals("date")) { + return "@matchesDatePattern('yyyy-MM-dd')@"; + } else if (schema.format != null && schema.format.equals("date-time")) { + return "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ssZ')@"; + } else if (hasText(schema.pattern)) { + return String.format("@matches(%s)@", schema.pattern); + } else if (!isEmpty(schema.enum_)) { + return String.format("@matches(%s)@", + String.join("|", schema.enum_)); + } else { + return "@notEmpty()@"; + } + case OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_INTEGER: + return "@isNumber()@"; + case "boolean" : + return "@matches(true|false)@"; + default: + return "@ignore@"; + } + } + + /** + * Create validation expression using regex according to schema type and format. + */ + public static String createValidationRegex(String name, @Nullable OasSchema oasSchema) { + + if (oasSchema != null && (OasModelHelper.isReferenceType(oasSchema) + || OasModelHelper.isObjectType(oasSchema))) { + throw new CitrusRuntimeException(String.format( + "Unable to create a validation regex for an reference of object schema '%s'!", + name)); + } + + return createValidationRegex(oasSchema); + } + + public static String createValidationRegex(@Nullable OasSchema schema) { + + if (schema == null) { + return ""; + } + + switch (schema.type) { + case "string" : + if (schema.format != null && schema.format.equals("date")) { + return "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])"; + } else if (schema.format != null && schema.format.equals("date-time")) { + return "\\d{4}-\\d{2}-\\d{2}T[01]\\d:[0-5]\\d:[0-5]\\dZ"; + } else if (hasText(schema.pattern)) { + return schema.pattern; + } else if (!isEmpty(schema.enum_)) { + return "(" + (String.join("|", schema.enum_)) + ")"; + } else if (schema.format != null && schema.format.equals("uuid")) { + return "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; + } else { + return ".*"; + } + case OpenApiConstants.TYPE_NUMBER: + return "[0-9]+\\.?[0-9]*"; + case "integer" : + return "[0-9]+"; + case "boolean" : + return "(true|false)"; + default: + return ""; + } + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java index 0d1a3ffe1d..4ad4c509f6 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java @@ -17,6 +17,7 @@ package org.citrusframework.openapi.actions; import jakarta.annotation.Nullable; +import java.net.URL; import org.citrusframework.TestAction; import org.citrusframework.TestActionBuilder; import org.citrusframework.endpoint.Endpoint; @@ -27,8 +28,6 @@ import org.citrusframework.spi.ReferenceResolverAware; import org.citrusframework.util.ObjectHelper; -import java.net.URL; - /** * Action executes client and server operations using given OpenApi specification. * Action creates proper request and response data from given specification rules. diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java index 002058b8f1..1c86454b0b 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java @@ -19,7 +19,12 @@ import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; +import org.citrusframework.actions.SendMessageAction; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.actions.HttpClientRequestActionBuilder; @@ -34,18 +39,19 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.regex.Pattern; - /** * @author Christoph Deppisch * @since 4.1 */ public class OpenApiClientRequestActionBuilder extends HttpClientRequestActionBuilder { - private final OpenApiRequestValidationProcessor openApiRequestValidationProcessor; + private final OpenApiSpecification openApiSpec; + + private final String operationId; + + private boolean oasValidationEnabled = true; + + private OpenApiRequestValidationProcessor openApiRequestValidationProcessor; /** * Default constructor initializes http request message builder. @@ -58,14 +64,23 @@ public OpenApiClientRequestActionBuilder(HttpMessage httpMessage, OpenApiSpecifi String operationId) { super(new OpenApiClientRequestMessageBuilder(httpMessage, openApiSpec, operationId), httpMessage); - openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId); - process(openApiRequestValidationProcessor); - } + this.openApiSpec = openApiSpec; + this.operationId = operationId; + } + + @Override + public SendMessageAction doBuild() { - public OpenApiClientRequestActionBuilder disableOasValidation(boolean b) { - if (openApiRequestValidationProcessor != null) { - openApiRequestValidationProcessor.setEnabled(!b); + if (oasValidationEnabled && !messageProcessors.contains(openApiRequestValidationProcessor)) { + openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId); + process(openApiRequestValidationProcessor); } + + return super.doBuild(); + } + + public OpenApiClientRequestActionBuilder disableOasValidation(boolean disabled) { + oasValidationEnabled = !disabled; return this; } @@ -118,7 +133,7 @@ private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter if (context.getVariables().containsKey(parameter.getName())) { parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + CitrusSettings.VARIABLE_SUFFIX; } else { - parameterValue = OpenApiTestDataGenerator.createRandomValueExpression((OasSchema) parameter.schema); + parameterValue = OpenApiTestDataGenerator.createRandomValueExpression((OasSchema) parameter.schema, false); } randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") .matcher(randomizedPath) @@ -172,4 +187,5 @@ private void setSpecifiedHeaders(TestContext context, OasOperation operation) { }); } } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java index 129f118052..302a75e9d9 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java @@ -20,7 +20,12 @@ import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import jakarta.annotation.Nullable; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; +import org.citrusframework.actions.ReceiveMessageAction; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.actions.HttpClientResponseActionBuilder; @@ -29,7 +34,7 @@ import org.citrusframework.message.Message; import org.citrusframework.message.MessageType; import org.citrusframework.openapi.OpenApiSpecification; -import org.citrusframework.openapi.OpenApiTestDataGenerator; +import org.citrusframework.openapi.OpenApiTestValidationDataGenerator; import org.citrusframework.openapi.model.OasModelHelper; import org.citrusframework.openapi.model.OperationPathAdapter; import org.citrusframework.openapi.validation.OpenApiResponseValidationProcessor; @@ -37,18 +42,19 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import java.util.Collection; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; - /** * @author Christoph Deppisch * @since 4.1 */ public class OpenApiClientResponseActionBuilder extends HttpClientResponseActionBuilder { - private final OpenApiResponseValidationProcessor openApiResponseValidationProcessor; + private OpenApiResponseValidationProcessor openApiResponseValidationProcessor; + + private final OpenApiSpecification openApiSpec; + + private final String operationId; + + private boolean oasValidationEnabled = true; /** * Default constructor initializes http response message builder. @@ -63,15 +69,24 @@ public OpenApiClientResponseActionBuilder(HttpMessage httpMessage, String operationId, String statusCode) { super(new OpenApiClientResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode), httpMessage); - - openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, operationId); - validate(openApiResponseValidationProcessor); + this.openApiSpec = openApiSpec; + this.operationId = operationId; } - public OpenApiClientResponseActionBuilder disableOasValidation(boolean b) { - if (openApiResponseValidationProcessor != null) { - openApiResponseValidationProcessor.setEnabled(!b); + @Override + public ReceiveMessageAction doBuild() { + + if (oasValidationEnabled && !messageProcessors.contains(openApiResponseValidationProcessor)) { + openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, operationId); + validate(openApiResponseValidationProcessor); } + + return super.doBuild(); + } + + public OpenApiClientResponseActionBuilder disableOasValidation(boolean disable) { + oasValidationEnabled = !disable; + ((OpenApiClientResponseMessageBuilder)getMessageBuilderSupport().getMessageBuilder()).setOasValidationEnabled(oasValidationEnabled); return this; } @@ -89,12 +104,10 @@ public static void fillMessageFromResponse(OpenApiSpecification openApiSpecifica Optional responseSchema = OasModelHelper.getSchema(response); responseSchema.ifPresent(oasSchema -> { httpMessage.setPayload( - OpenApiTestDataGenerator.createInboundPayload(oasSchema, + OpenApiTestValidationDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions( openApiSpecification.getOpenApiDoc(context)), openApiSpecification)); - // Best guess for the content type. Currently, we can only determine the content type - // for sure for json. Other content types will be neglected. OasSchema resolvedSchema = OasModelHelper.resolveSchema( openApiSpecification.getOpenApiDoc(null), oasSchema); if (OasModelHelper.isObjectType(resolvedSchema) || OasModelHelper.isObjectArrayType( @@ -109,7 +122,6 @@ public static void fillMessageFromResponse(OpenApiSpecification openApiSpecifica } } ); - } private static void fillRequiredHeaders( @@ -119,7 +131,7 @@ private static void fillRequiredHeaders( Map requiredHeaders = OasModelHelper.getRequiredHeaders(response); for (Map.Entry header : requiredHeaders.entrySet()) { httpMessage.setHeader(header.getKey(), - OpenApiTestDataGenerator.createValidationExpression(header.getKey(), + OpenApiTestValidationDataGenerator.createValidationExpression(header.getKey(), header.getValue(), OasModelHelper.getSchemaDefinitions( openApiSpecification.getOpenApiDoc(context)), false, @@ -146,6 +158,8 @@ private static class OpenApiClientResponseMessageBuilder extends HttpMessageBuil private final HttpMessage httpMessage; + private boolean oasValidationEnabled = true; + public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec, String operationId, String statusCode) { @@ -170,7 +184,7 @@ public Message build(TestContext context, String messageType) { private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter, TestContext context) { OasOperation operation = operationPathAdapter.operation(); - if (operation.responses != null) { + if (oasValidationEnabled && operation.responses != null) { Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( openApiSpec.getOpenApiDoc(context), operation, statusCode, null); @@ -185,5 +199,9 @@ private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter httpMessage.status(HttpStatus.OK); } } + + public void setOasValidationEnabled(boolean oasValidationEnabled) { + this.oasValidationEnabled = oasValidationEnabled; + } } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java index 43816b8c37..316add2fd3 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java @@ -16,10 +16,23 @@ package org.citrusframework.openapi.actions; +import static java.lang.String.format; +import static org.citrusframework.message.MessageType.JSON; +import static org.citrusframework.message.MessageType.PLAINTEXT; +import static org.citrusframework.message.MessageType.XML; +import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType; +import static org.citrusframework.util.StringUtils.appendSegmentToUrlPath; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; + import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasParameter; import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; +import org.citrusframework.actions.ReceiveMessageAction; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.actions.HttpServerRequestActionBuilder; @@ -27,33 +40,26 @@ import org.citrusframework.http.message.HttpMessageBuilder; import org.citrusframework.message.Message; import org.citrusframework.openapi.OpenApiSpecification; -import org.citrusframework.openapi.OpenApiTestDataGenerator; +import org.citrusframework.openapi.OpenApiTestValidationDataGenerator; import org.citrusframework.openapi.model.OasModelHelper; import org.citrusframework.openapi.model.OperationPathAdapter; import org.citrusframework.openapi.validation.OpenApiRequestValidationProcessor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import java.util.List; -import java.util.Optional; -import java.util.regex.Pattern; - -import static java.lang.String.format; -import static org.citrusframework.message.MessageType.JSON; -import static org.citrusframework.message.MessageType.PLAINTEXT; -import static org.citrusframework.message.MessageType.XML; -import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType; -import static org.citrusframework.util.StringUtils.appendSegmentToUrlPath; -import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; -import static org.springframework.http.MediaType.APPLICATION_XML_VALUE; - /** * @author Christoph Deppisch * @since 4.1 */ public class OpenApiServerRequestActionBuilder extends HttpServerRequestActionBuilder { - private final OpenApiRequestValidationProcessor openApiRequestValidationProcessor; + private OpenApiRequestValidationProcessor openApiRequestValidationProcessor; + + private final OpenApiSpecification openApiSpec; + + private final String operationId; + + private boolean oasValidationEnabled = true; /** * Default constructor initializes http request message builder. @@ -67,15 +73,23 @@ public OpenApiServerRequestActionBuilder(HttpMessage httpMessage, String operationId) { super(new OpenApiServerRequestMessageBuilder(httpMessage, openApiSpec, operationId), httpMessage); - - openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId); - validate(openApiRequestValidationProcessor); + this.openApiSpec = openApiSpec; + this.operationId = operationId; } - public OpenApiServerRequestActionBuilder disableOasValidation(boolean b) { - if (openApiRequestValidationProcessor != null) { - openApiRequestValidationProcessor.setEnabled(!b); + @Override + public ReceiveMessageAction doBuild() { + + if (oasValidationEnabled && !messageProcessors.contains(openApiRequestValidationProcessor)) { + openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId); + validate(openApiRequestValidationProcessor); } + + return super.doBuild(); + } + + public OpenApiServerRequestActionBuilder disableOasValidation(boolean disable) { + oasValidationEnabled = !disable; return this; } @@ -143,7 +157,7 @@ private void setSpecifiedBody(TestContext context, OperationPathAdapter operatio Optional body = OasModelHelper.getRequestBodySchema( openApiSpec.getOpenApiDoc(context), operationPathAdapter.operation()); body.ifPresent(oasSchema -> httpMessage.setPayload( - OpenApiTestDataGenerator.createInboundPayload(oasSchema, + OpenApiTestValidationDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions( openApiSpec.getOpenApiDoc(context)), openApiSpec))); } @@ -162,7 +176,7 @@ private String determinePath(TestContext context, OasOperation operation, .matcher(randomizedPath) .replaceAll(parameterValue); } else { - parameterValue = OpenApiTestDataGenerator.createValidationRegex( + parameterValue = OpenApiTestValidationDataGenerator.createValidationRegex( parameter.getName(), OasModelHelper.getParameterSchema(parameter).orElse(null)); @@ -189,7 +203,7 @@ private void setSpecifiedQueryParameters(TestContext context, param -> (param.required != null && param.required) || context.getVariables() .containsKey(param.getName())) .forEach(param -> httpMessage.queryParam(param.getName(), - OpenApiTestDataGenerator.createValidationExpression(param.getName(), + OpenApiTestValidationDataGenerator.createValidationExpression(param.getName(), OasModelHelper.getParameterSchema(param).orElse(null), OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), false, openApiSpec, @@ -210,7 +224,7 @@ private void setSpecifiedHeaders(TestContext context, param -> (param.required != null && param.required) || context.getVariables() .containsKey(param.getName())) .forEach(param -> httpMessage.setHeader(param.getName(), - OpenApiTestDataGenerator.createValidationExpression(param.getName(), + OpenApiTestValidationDataGenerator.createValidationExpression(param.getName(), OasModelHelper.getParameterSchema(param).orElse(null), OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), false, openApiSpec, diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java index 46cb3fbcab..9384943f32 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java @@ -16,11 +16,29 @@ package org.citrusframework.openapi.actions; +import static java.lang.Integer.parseInt; +import static java.util.Collections.singletonMap; +import static org.citrusframework.openapi.OpenApiTestDataGenerator.createOutboundPayload; +import static org.citrusframework.openapi.OpenApiTestDataGenerator.createRandomValueExpression; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; + import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; import org.citrusframework.CitrusSettings; +import org.citrusframework.actions.SendMessageAction; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.http.actions.HttpServerResponseActionBuilder; @@ -37,31 +55,19 @@ import org.citrusframework.openapi.validation.OpenApiResponseValidationProcessor; import org.springframework.http.HttpStatus; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; -import java.util.function.Predicate; -import java.util.regex.Pattern; - -import static java.lang.Integer.parseInt; -import static java.util.Collections.singletonMap; -import static org.citrusframework.openapi.OpenApiTestDataGenerator.createOutboundPayload; -import static org.citrusframework.openapi.OpenApiTestDataGenerator.createRandomValueExpression; -import static org.springframework.http.HttpStatus.OK; -import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; -import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; - /** * @author Christoph Deppisch * @since 4.1 */ public class OpenApiServerResponseActionBuilder extends HttpServerResponseActionBuilder { - private final OpenApiResponseValidationProcessor openApiResponseValidationProcessor; + private OpenApiResponseValidationProcessor openApiResponseValidationProcessor; + + private final OpenApiSpecification openApiSpec; + + private final String operationId; + + private boolean oasValidationEnabled = true; /** * Default constructor initializes http response message builder. @@ -76,16 +82,23 @@ public OpenApiServerResponseActionBuilder(HttpMessage httpMessage, String operationId, String statusCode, String accept) { super(new OpenApiServerResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode, accept), httpMessage); - - openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, - operationId); - process(openApiResponseValidationProcessor); + this.openApiSpec = openApiSpec; + this.operationId = operationId; } - public OpenApiServerResponseActionBuilder disableOasValidation(boolean b) { - if (openApiResponseValidationProcessor != null) { - openApiResponseValidationProcessor.setEnabled(!b); + @Override + public SendMessageAction doBuild() { + + if (oasValidationEnabled && !messageProcessors.contains(openApiResponseValidationProcessor)) { + openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, operationId); + process(openApiResponseValidationProcessor); } + + return super.doBuild(); + } + + public OpenApiServerResponseActionBuilder disableOasValidation(boolean disable) { + oasValidationEnabled = !disable; return this; } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java index a208899e7c..f703be9b92 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java @@ -16,6 +16,10 @@ package org.citrusframework.openapi.model; +import static java.util.Collections.singletonList; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_ARRAY; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_OBJECT; + import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter; import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; @@ -36,11 +40,6 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Response; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; import jakarta.annotation.Nullable; -import org.citrusframework.openapi.model.v2.Oas20ModelHelper; -import org.citrusframework.openapi.model.v3.Oas30ModelHelper; -import org.citrusframework.util.StringUtils; -import org.springframework.http.MediaType; - import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -53,15 +52,17 @@ import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; - -import static java.util.Collections.singletonList; +import org.citrusframework.openapi.model.v2.Oas20ModelHelper; +import org.citrusframework.openapi.model.v3.Oas30ModelHelper; +import org.citrusframework.util.StringUtils; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch */ public final class OasModelHelper { - public static final String DEFAULT_ = "default_"; + public static final String DEFAULT = "default_"; /** * List of preferred media types in the order of priority, @@ -79,7 +80,7 @@ private OasModelHelper() { * @return true if given schema is an object. */ public static boolean isObjectType(@Nullable OasSchema schema) { - return schema != null && "object".equals(schema.type); + return schema != null && TYPE_OBJECT.equals(schema.type); } /** @@ -88,7 +89,7 @@ public static boolean isObjectType(@Nullable OasSchema schema) { * @return true if given schema is an array. */ public static boolean isArrayType(@Nullable OasSchema schema) { - return schema != null && "array".equals(schema.type); + return schema != null && TYPE_ARRAY.equals(schema.type); } /** @@ -98,7 +99,7 @@ public static boolean isArrayType(@Nullable OasSchema schema) { */ public static boolean isObjectArrayType(@Nullable OasSchema schema) { - if (schema == null || !"array".equals(schema.type)) { + if (schema == null || !TYPE_ARRAY.equals(schema.type)) { return false; } @@ -293,7 +294,7 @@ public static Optional getResponseForRandomGeneration(OasDocument o Predicate acceptedSchemas = resp -> getSchema(operation, resp, accept != null ? singletonList(accept) : DEFAULT_ACCEPTED_MEDIA_TYPES).isPresent(); // Fallback 1: Pick the default if it exists - Optional response = Optional.ofNullable(responseMap.get(DEFAULT_)); + Optional response = Optional.ofNullable(responseMap.get(DEFAULT)); if (response.isEmpty()) { // Fallback 2: Pick the response object related to the first 2xx, providing an accepted schema @@ -466,7 +467,7 @@ private static boolean isOas20(OasDocument openApiDoc) { * This method iterates over the responses contained in the {@link OasResponses} object. If a response has a reference * (indicated by a non-null {@code $ref} field), it resolves the reference and adds the resolved response to the result list. * Non-referenced responses are added to the result list as-is. The resulting map includes the default response under - * the key {@link OasModelHelper#DEFAULT_}, if it exists. + * the key {@link OasModelHelper#DEFAULT}, if it exists. *

* * @param responses the {@link OasResponses} instance containing the responses to be resolved. @@ -494,10 +495,10 @@ private static Map resolveResponses(OasDocument openApiDoc, if (responses.default_.$ref != null) { OasResponse resolved = responseResolver.apply(responses.default_.$ref); if (resolved != null) { - responseMap.put(DEFAULT_, resolved); + responseMap.put(DEFAULT, resolved); } } else { - responseMap.put(DEFAULT_, responses.default_); + responseMap.put(DEFAULT, responses.default_); } } @@ -507,8 +508,8 @@ private static Map resolveResponses(OasDocument openApiDoc, private static Function getResponseResolver( OasDocument openApiDoc) { return delegate(openApiDoc, - (Function>) doc -> (responseRef -> doc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))), - (Function>) doc -> (responseRef -> doc.components.responses.get(OasModelHelper.getReferenceName(responseRef)))); + doc -> (responseRef -> doc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))), + doc -> (responseRef -> doc.components.responses.get(OasModelHelper.getReferenceName(responseRef)))); } /** diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java index 987ed05c90..c1a1999ac9 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java @@ -17,7 +17,7 @@ package org.citrusframework.openapi.model; import io.apicurio.datamodels.openapi.models.OasOperation; -import org.citrusframework.openapi.OpenApiUtils; +import org.citrusframework.openapi.util.OpenApiUtils; import static java.lang.String.format; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java similarity index 79% rename from connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java rename to connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java index 21fe3d32cc..dd6e48deb7 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiUtils.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java @@ -14,16 +14,18 @@ * limitations under the License. */ -package org.citrusframework.openapi; +package org.citrusframework.openapi.util; + +import static java.lang.String.format; import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasSchema; import jakarta.annotation.Nonnull; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.OpenApiConstants; import org.citrusframework.util.StringUtils; -import static java.lang.String.format; - public class OpenApiUtils { private OpenApiUtils() { @@ -35,7 +37,7 @@ public static String getMethodPath(@Nonnull HttpMessage httpMessage) { Object path = httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI); return getMethodPath(methodHeader != null ? methodHeader.toString().toLowerCase() : "null", - path != null? path.toString() : "null"); + path != null ? path.toString() : "null"); } public static String getMethodPath(@Nonnull String method, @Nonnull String path) { @@ -52,4 +54,12 @@ public static String createFullPathOperationIdentifier(String path, OasOperation return format("%s_%s", oasOperation.getMethod().toUpperCase(), path); } + public static boolean isAnyNumberScheme(OasSchema schema) { + return ( + schema != null && + (OpenApiConstants.TYPE_INTEGER.equalsIgnoreCase(schema.type) || + OpenApiConstants.TYPE_NUMBER.equalsIgnoreCase(schema.type)) + ); + } + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java new file mode 100644 index 0000000000..4a31733459 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java @@ -0,0 +1,116 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.util; + +import java.util.ArrayList; +import java.util.LinkedHashMap; + +/** + * Interface representing a random element in a JSON structure. This interface provides default + * methods to push values into the element, which can be overridden by implementing classes. + */ +public interface RandomElement { + + default void push(Object value) { + throw new UnsupportedOperationException(); + } + + default void push(String key, Object value) { + throw new UnsupportedOperationException(); + } + + /** + * A random element representing an array. Array elements can be of type String (native + * attribute) or {@link RandomElement}. + */ + class RandomList extends ArrayList implements RandomElement { + + @Override + public void push(Object value) { + add(value); + } + + @Override + public void push(String key, Object value) { + if (!isEmpty()) { + Object lastElement = get(size() - 1); + if (lastElement instanceof RandomElement randomElement) { + randomElement.push(key, value); + } + } + } + } + + /** + * A random object representing a JSON object, with attributes stored as key-value pairs. Values + * are of type String (simple attributes) or {@link RandomElement}. + */ + class RandomObject extends LinkedHashMap implements RandomElement { + + @Override + public void push(String key, Object value) { + put(key, value); + } + + @Override + public void push(Object value) { + if (value instanceof RandomObject randomObject) { + this.putAll(randomObject); + return; + } + RandomElement.super.push(value); + } + } + + /** + * A random value that either holds a String (simple property) or a random element. + */ + class RandomValue implements RandomElement { + + private Object value; + + public RandomValue() { + } + + public RandomValue(Object value) { + this.value = value; + } + + public Object getValue() { + return value; + } + + @Override + public void push(Object pushedValue) { + if (value instanceof RandomElement randomElement) { + randomElement.push(pushedValue); + } else { + this.value = pushedValue; + } + } + + @Override + public void push(String key, Object pushedValue) { + if (value instanceof RandomElement randomElement) { + randomElement.push(key, pushedValue); + } else { + throw new IllegalStateException("Cannot push key/value to value: " + value); + } + } + + } +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java new file mode 100644 index 0000000000..43346b388a --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java @@ -0,0 +1,109 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.util; + +import java.util.ArrayDeque; +import java.util.Deque; +import org.citrusframework.openapi.util.RandomElement.RandomList; +import org.citrusframework.openapi.util.RandomElement.RandomObject; +import org.citrusframework.openapi.util.RandomElement.RandomValue; + +/** + * RandomModelBuilder is a class for building random JSON models. It supports adding + * simple values, objects, properties, and arrays to the JSON structure. The final + * model can be converted to a JSON string using the `writeToJson` method. I + *

+ * The builder is able to build nested structures and can also handle native string, + * number, and boolean elements, represented as functions for later dynamic string + * conversion by Citrus. + *

+ * Example usage: + *

+ * RandomModelBuilder builder = new RandomModelBuilder();
+ * builder.object(() -> {
+ *     builder.property("key1", () -> builder.appendSimple("value1"));
+ *     builder.property("key2", () -> builder.array(() -> {
+ *         builder.appendSimple("value2");
+ *         builder.appendSimple("value3");
+ *     }));
+ * });
+ * String json = builder.writeToJson();
+ * 
+ */ +public class RandomModelBuilder { + + final Deque deque = new ArrayDeque<>(); + + public RandomModelBuilder() { + deque.push(new RandomValue()); + } + + public String toString() { + return RandomModelWriter.toString(this); + } + + public void appendSimple(String nativeValue) { + if (deque.isEmpty()) { + deque.push(new RandomValue(nativeValue)); + } else { + deque.peek().push(nativeValue); + } + } + + public void object(Runnable objectBuilder) { + if (deque.isEmpty()) { + throwIllegalState(); + } + + RandomObject randomObject = new RandomObject(); + deque.peek().push(randomObject); + objectBuilder.run(); + } + + private static void throwIllegalState() { + throw new IllegalStateException("Encountered empty stack!"); + } + + public void property(String key, Runnable valueBuilder) { + if (deque.isEmpty()) { + throwIllegalState(); + } + + RandomValue randomValue = new RandomValue(); + deque.peek().push(key, randomValue); + + deque.push(randomValue); + valueBuilder.run(); + deque.pop(); + } + + public void array(Runnable arrayBuilder) { + if (deque.isEmpty()) { + throwIllegalState(); + } + RandomList randomList = new RandomList(); + deque.peek().push(randomList); + + // For a list, we need to push the list to the queue. This is because when the builder adds elements + // to the list, and we are dealing with nested lists, we can otherwise not distinguish whether to put + // an element into the list or into the nested list. + deque.push(randomList); + arrayBuilder.run(); + deque.pop(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java new file mode 100644 index 0000000000..11960d0973 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java @@ -0,0 +1,115 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.util; + +import static org.citrusframework.util.StringUtils.trimTrailingComma; + +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import org.citrusframework.openapi.util.RandomElement.RandomValue; + +/** + * Utility class for converting a {@link RandomModelBuilder} to its string representation. + * This class provides static methods to serialize the model built by {@link RandomModelBuilder}. + */ +class RandomModelWriter { + + private RandomModelWriter() { + // static access only + } + + static String toString(RandomModelBuilder randomModelBuilder) { + + StringBuilder builder = new StringBuilder(); + appendObject(builder, randomModelBuilder.deque); + return builder.toString(); + } + + private static void appendObject(StringBuilder builder, Object object) { + + if (object instanceof Deque deque) { + while (!deque.isEmpty()) { + appendObject(builder, deque.pop()); + } + return; + } + if (object instanceof Map map) { + //noinspection unchecked + appendMap(builder, (Map) map); + } else if (object instanceof List list) { + appendArray(builder, list); + } else if (object instanceof String string) { + builder.append(string); + } else if (object instanceof RandomValue randomValue) { + appendObject(builder, randomValue.getValue()); + } + } + + private static void appendArray(StringBuilder builder, List list) { + builder.append("["); + list.forEach(listValue -> { + appendObject(builder, listValue); + builder.append(","); + }); + trimTrailingComma(builder); + builder.append("]"); + } + + private static void appendMap(StringBuilder builder, Map map) { + if (map.size() == 1) { + Entry entry = map.entrySet().iterator().next(); + String key = entry.getKey(); + Object value = entry.getValue(); + + if ("ARRAY".equals(key)) { + appendObject(builder, value); + } else if ("NATIVE".equals(key)) { + builder.append(value); + } else { + appendJsonObject(builder, map); + } + } else { + appendJsonObject(builder, map); + } + } + + private static void appendJsonObject(StringBuilder builder, Map map) { + builder.append("{"); + for (Entry entry : map.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + builder.append("\""); + builder.append(key); + builder.append("\": "); + + if (value instanceof String) { + builder.append(value); + } else if (value instanceof Map) { + appendObject(builder, value); + } else if (value instanceof RandomValue randomValue) { + appendObject(builder, randomValue.getValue()); + } + + builder.append(","); + } + trimTrailingComma(builder); + + builder.append("}"); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java index b640adb365..cb14d44c89 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java @@ -33,28 +33,25 @@ public class OpenApiRequestValidationProcessor implements private final String operationId; - private boolean enabled = true; + private final OpenApiRequestValidator openApiRequestValidator; public OpenApiRequestValidationProcessor(OpenApiSpecification openApiSpecification, String operationId) { - this.operationId = operationId; this.openApiSpecification = openApiSpecification; + this.operationId = operationId; + this.openApiRequestValidator = new OpenApiRequestValidator(openApiSpecification); } - @Override public void validate(Message message, TestContext context) { - if (!enabled || !(message instanceof HttpMessage httpMessage)) { + if (!(message instanceof HttpMessage httpMessage)) { return; } + openApiSpecification.getOperation( operationId, context).ifPresent(operationPathAdapter -> - openApiSpecification.getRequestValidator().ifPresent(openApiRequestValidator -> - openApiRequestValidator.validateRequest(operationPathAdapter, httpMessage))); + openApiRequestValidator.validateRequest(operationPathAdapter, httpMessage)); } - public void setEnabled(boolean b) { - this.enabled = b; - } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java index bef2c35230..94aa08ae9a 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java @@ -16,28 +16,27 @@ package org.citrusframework.openapi.validation; -import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request; import com.atlassian.oai.validator.model.SimpleRequest; import com.atlassian.oai.validator.report.ValidationReport; +import java.util.ArrayList; +import java.util.Collection; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.http.message.HttpMessageUtils; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; -import java.util.ArrayList; -import java.util.Collection; - -import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledlobally; - /** * Specific validator that uses atlassian and is responsible for validating HTTP requests * against an OpenAPI specification using the provided {@code OpenApiInteractionValidator}. */ public class OpenApiRequestValidator extends OpenApiValidator { - public OpenApiRequestValidator(OpenApiInteractionValidator openApiInteractionValidator) { - super(openApiInteractionValidator, isRequestValidationEnabledlobally()); + public OpenApiRequestValidator(OpenApiSpecification openApiSpecification) { + super(openApiSpecification); + setEnabled(openApiSpecification.getSwaggerOpenApiValidationContext() != null && openApiSpecification.getSwaggerOpenApiValidationContext().isRequestValidationEnabled()); } @Override @@ -79,7 +78,7 @@ Request createRequestFromMessage(OperationPathAdapter operationPathAdapter, SimpleRequest.Builder finalRequestBuilder = requestBuilder; finalRequestBuilder.withAccept(httpMessage.getAccept()); - httpMessage.getQueryParams() + HttpMessageUtils.getQueryParameterMap(httpMessage) .forEach((key, value) -> finalRequestBuilder.withQueryParam(key, new ArrayList<>( value))); diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java index 18754062f1..c098fda6a0 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java @@ -23,7 +23,8 @@ import org.citrusframework.validation.ValidationProcessor; /** - * {@code ValidationProcessor} that delegates validation of OpenApi responses to instances of {@link OpenApiResponseValidator}. + * {@code ValidationProcessor} that delegates validation of OpenApi responses to instances of + * {@link OpenApiResponseValidator}. */ public class OpenApiResponseValidationProcessor implements ValidationProcessor { @@ -32,27 +33,25 @@ public class OpenApiResponseValidationProcessor implements private final String operationId; - private boolean enabled = true; + private final OpenApiResponseValidator openApiResponseValidator; - public OpenApiResponseValidationProcessor(OpenApiSpecification openApiSpecification, String operationId) { + public OpenApiResponseValidationProcessor(OpenApiSpecification openApiSpecification, + String operationId) { this.operationId = operationId; this.openApiSpecification = openApiSpecification; + this.openApiResponseValidator = new OpenApiResponseValidator(openApiSpecification); } @Override public void validate(Message message, TestContext context) { - if (!enabled || !(message instanceof HttpMessage httpMessage)) { + if (!(message instanceof HttpMessage httpMessage)) { return; } openApiSpecification.getOperation( operationId, context).ifPresent(operationPathAdapter -> - openApiSpecification.getResponseValidator().ifPresent(openApiResponseValidator -> - openApiResponseValidator.validateResponse(operationPathAdapter, httpMessage))); + openApiResponseValidator.validateResponse(operationPathAdapter, httpMessage)); } - public void setEnabled(boolean b) { - this.enabled = b; - } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java index 9aba9b0764..faefe24a9b 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java @@ -16,26 +16,25 @@ package org.citrusframework.openapi.validation; -import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request.Method; import com.atlassian.oai.validator.model.Response; import com.atlassian.oai.validator.model.SimpleResponse; import com.atlassian.oai.validator.report.ValidationReport; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; import org.springframework.http.HttpStatusCode; -import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; - /** * Specific validator, that facilitates the use of Atlassian's Swagger Request Validator, * and delegates validation of OpenApi requests to instances of {@link OpenApiRequestValidator}. */ public class OpenApiResponseValidator extends OpenApiValidator { - public OpenApiResponseValidator(OpenApiInteractionValidator openApiInteractionValidator) { - super(openApiInteractionValidator, isResponseValidationEnabledGlobally()); + public OpenApiResponseValidator(OpenApiSpecification openApiSpecification) { + super(openApiSpecification); + setEnabled(openApiSpecification.getSwaggerOpenApiValidationContext() != null && openApiSpecification.getSwaggerOpenApiValidationContext().isResponseValidationEnabled()); } @Override diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java index a6bc2e98c8..c5393f8051 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java @@ -18,6 +18,7 @@ import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.report.ValidationReport; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; public abstract class OpenApiValidator { @@ -26,9 +27,14 @@ public abstract class OpenApiValidator { protected boolean enabled; - protected OpenApiValidator(OpenApiInteractionValidator openApiInteractionValidator, boolean enabled) { - this.openApiInteractionValidator = openApiInteractionValidator; - this.enabled = enabled; + protected OpenApiValidator(OpenApiSpecification openApiSpecification) { + SwaggerOpenApiValidationContext swaggerOpenApiValidationContext = openApiSpecification.getSwaggerOpenApiValidationContext(); + if (swaggerOpenApiValidationContext != null) { + openApiInteractionValidator = openApiSpecification.getSwaggerOpenApiValidationContext() + .getOpenApiInteractionValidator(); + } else { + openApiInteractionValidator = null; + } } public void setEnabled(boolean enabled) { diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java new file mode 100644 index 0000000000..ad9dbcf23e --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java @@ -0,0 +1,77 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.report.MessageResolver; +import com.atlassian.oai.validator.schema.SchemaValidator; +import com.atlassian.oai.validator.schema.SwaggerV20Library; +import io.swagger.v3.oas.models.OpenAPI; + +public class SwaggerOpenApiValidationContext { + + private final OpenAPI openApi; + + private OpenApiInteractionValidator openApiInteractionValidator; + + private SchemaValidator schemaValidator; + + private boolean responseValidationEnabled = isResponseValidationEnabledGlobally(); + + private boolean requestValidationEnabled = isRequestValidationEnabledGlobally(); + + public SwaggerOpenApiValidationContext(OpenAPI openApi) { + this.openApi = openApi; + } + + public OpenAPI getSwaggerOpenApi() { + return openApi; + } + + public synchronized OpenApiInteractionValidator getOpenApiInteractionValidator() { + if (openApiInteractionValidator == null) { + openApiInteractionValidator = new OpenApiInteractionValidator.Builder().withApi(openApi).build(); + } + return openApiInteractionValidator; + } + + public synchronized SchemaValidator getSchemaValidator() { + if (schemaValidator == null) { + schemaValidator = new SchemaValidator(openApi, new MessageResolver(), SwaggerV20Library::schemaFactory); + } + return schemaValidator; + } + + public boolean isResponseValidationEnabled() { + return responseValidationEnabled; + } + + public void setResponseValidationEnabled(boolean responseValidationEnabled) { + this.responseValidationEnabled = responseValidationEnabled; + } + + public boolean isRequestValidationEnabled() { + return requestValidationEnabled; + } + + public void setRequestValidationEnabled(boolean requestValidationEnabled) { + this.requestValidationEnabled = requestValidationEnabled; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java new file mode 100644 index 0000000000..51d0ba4412 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java @@ -0,0 +1,78 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.OpenApiInteractionValidator.SpecSource; +import com.atlassian.oai.validator.util.OpenApiLoader; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.core.models.ParseOptions; +import jakarta.annotation.Nonnull; +import java.net.URL; +import java.util.Collections; +import org.citrusframework.openapi.OpenApiResourceLoader; +import org.citrusframework.spi.Resource; + +/** + * Utility class for loading Swagger OpenAPI specifications from various resources. + */ +public abstract class SwaggerOpenApiValidationContextLoader { + + private SwaggerOpenApiValidationContextLoader() { + // Static access only + } + /** + * Loads an OpenAPI specification from a secured web resource. + * + * @param url the URL of the secured web resource + * @return the loaded OpenAPI specification + */ + public static SwaggerOpenApiValidationContext fromSecuredWebResource(@Nonnull URL url) { + return createValidationContext(new OpenApiLoader().loadApi(SpecSource.inline(OpenApiResourceLoader.rawFromSecuredWebResource(url)), Collections.emptyList(), defaultParseOptions())); + } + + /** + * Loads an OpenAPI specification from a web resource. + * + * @param url the URL of the web resource + * @return the loaded OpenAPI specification + */ + public static SwaggerOpenApiValidationContext fromWebResource(@Nonnull URL url) { + return createValidationContext(new OpenApiLoader().loadApi(SpecSource.inline(OpenApiResourceLoader.rawFromWebResource(url)), Collections.emptyList(), defaultParseOptions())); + } + + /** + * Loads an OpenAPI specification from a file. + * + * @param resource the file resource containing the OpenAPI specification + * @return the loaded OpenAPI specification + */ + public static SwaggerOpenApiValidationContext fromFile(@Nonnull Resource resource) { + return createValidationContext(new OpenApiLoader().loadApi(SpecSource.inline(OpenApiResourceLoader.rawFromFile(resource)), Collections.emptyList(), defaultParseOptions())); + } + + private static SwaggerOpenApiValidationContext createValidationContext(OpenAPI openApi) { + return new SwaggerOpenApiValidationContext(openApi); + } + + private static ParseOptions defaultParseOptions() { + final ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setResolveFully(true); + parseOptions.setResolveCombinators(false); + return parseOptions; + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java index d4e448ec26..41f612b495 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java @@ -1,3 +1,19 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi; import org.testng.annotations.AfterMethod; @@ -17,7 +33,7 @@ public class OpenApiSettingsTest { private final EnvironmentVariables environmentVariables = new EnvironmentVariables(); - private static final boolean REQUEST_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isRequestValidationEnabledlobally(); + private static final boolean REQUEST_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isRequestValidationEnabledGlobally(); private static final boolean RESPONSE_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isResponseValidationEnabledGlobally(); @@ -66,33 +82,33 @@ public void afterMethod() throws Exception { public void testRequestValidationEnabledByProperty() throws Exception { environmentVariables.setup(); System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "true"); - assertTrue(OpenApiSettings.isRequestValidationEnabledlobally()); + assertTrue(OpenApiSettings.isRequestValidationEnabledGlobally()); } @Test public void testRequestValidationDisabledByProperty() throws Exception { environmentVariables.setup(); System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "false"); - assertFalse(OpenApiSettings.isRequestValidationEnabledlobally()); + assertFalse(OpenApiSettings.isRequestValidationEnabledGlobally()); } @Test public void testRequestValidationEnabledByEnvVar() throws Exception { environmentVariables.set(OpenApiSettings.REQUEST_VALIDATION_ENABLED_ENV, "true"); environmentVariables.setup(); - assertTrue(OpenApiSettings.isRequestValidationEnabledlobally()); + assertTrue(OpenApiSettings.isRequestValidationEnabledGlobally()); } @Test public void testRequestValidationDisabledByEnvVar() throws Exception { environmentVariables.set(OpenApiSettings.REQUEST_VALIDATION_ENABLED_ENV, "false"); environmentVariables.setup(); - assertFalse(OpenApiSettings.isRequestValidationEnabledlobally()); + assertFalse(OpenApiSettings.isRequestValidationEnabledGlobally()); } @Test public void testRequestValidationEnabledByDefault() { - assertTrue(OpenApiSettings.isRequestValidationEnabledlobally()); + assertTrue(OpenApiSettings.isRequestValidationEnabledGlobally()); } @Test diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java index 05f22c522a..668ecb9b64 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java @@ -1,3 +1,19 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi; import io.apicurio.datamodels.openapi.models.OasDocument; @@ -38,7 +54,6 @@ public class OpenApiSpecificationTest { - private static final String PING_API_HTTP_URL_STRING = "http://org.citrus.example.com/ping-api.yaml"; private static final String PING_API_HTTPS_URL_STRING = "https://org.citrus.example.com/ping-api.yaml"; @@ -85,27 +100,13 @@ public void tearDown() throws Exception { mockCloseable.close(); } - @Test - public void shouldInitializeFromSpecUrl() { - - // When - OpenApiSpecification specification = OpenApiSpecification.from(PING_API_HTTP_URL_STRING); - - // Then - assertNotNull(specification); - assertEquals(specification.getSpecUrl(), PING_API_HTTP_URL_STRING); - assertTrue(specification.getRequestValidator().isEmpty()); - assertTrue(specification.getResponseValidator().isEmpty()); - - } - @DataProvider(name = "protocollDataProvider") public static Object[][] protocolls() { return new Object[][] {{PING_API_HTTP_URL_STRING}, {PING_API_HTTPS_URL_STRING}}; } @Test(dataProvider = "protocollDataProvider") - public void shouldInitializeFromUrl(String urlString) throws Exception { + public void shouldInitializeFromUrl(String urlString) { // Given URL urlMock = mockUrlConnection(urlString); @@ -119,8 +120,7 @@ public void shouldInitializeFromUrl(String urlString) throws Exception { private void assertPingApi(OpenApiSpecification specification) { assertNotNull(specification); - assertTrue(specification.getRequestValidator().isPresent()); - assertTrue(specification.getResponseValidator().isPresent()); + assertNotNull(specification.getSwaggerOpenApiValidationContext()); Optional pingOperationPathAdapter = specification.getOperation( PING_OPERATION_ID, testContextMock); @@ -240,27 +240,25 @@ URL toSpecUrl(String resolvedSpecUrl) { when(endpointConfigurationMock.getRequestUrl()).thenReturn("http://org.citrus.sample"); // When - specification.setRequestValidationEnabled(false); + specification.setApiRequestValidationEnabled(false); // Then (not yet initialized) - assertFalse(specification.isRequestValidationEnabled()); - assertFalse(specification.getRequestValidator().isPresent()); + assertFalse(specification.isApiRequestValidationEnabled()); + assertNull(specification.getSwaggerOpenApiValidationContext()); // When (initialize) specification.getOpenApiDoc(testContextMock); // Then - assertFalse(specification.isRequestValidationEnabled()); - assertTrue(specification.getRequestValidator().isPresent()); - assertTrue(specification.getRequestValidator().isPresent()); + assertFalse(specification.isApiRequestValidationEnabled()); + assertNotNull(specification.getSwaggerOpenApiValidationContext()); // When - specification.setRequestValidationEnabled(true); + specification.setApiRequestValidationEnabled(true); // Then - assertTrue(specification.isRequestValidationEnabled()); - assertTrue(specification.getRequestValidator().isPresent()); - assertTrue(specification.getRequestValidator().get().isEnabled()); + assertTrue(specification.isApiRequestValidationEnabled()); + assertTrue(specification.getSwaggerOpenApiValidationContext().isRequestValidationEnabled()); } @@ -288,20 +286,19 @@ public void shouldDisableEnableResponseValidationWhenSet() { OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); // When - specification.setResponseValidationEnabled(false); + specification.setApiResponseValidationEnabled(false); // Then - assertFalse(specification.isResponseValidationEnabled()); - assertTrue(specification.getResponseValidator().isPresent()); - assertFalse(specification.getResponseValidator().get().isEnabled()); + assertFalse(specification.isApiResponseValidationEnabled()); + assertNotNull(specification.getSwaggerOpenApiValidationContext()); + assertFalse(specification.getSwaggerOpenApiValidationContext().isResponseValidationEnabled()); // When - specification.setResponseValidationEnabled(true); + specification.setApiResponseValidationEnabled(true); // Then - assertTrue(specification.isResponseValidationEnabled()); - assertTrue(specification.getResponseValidator().isPresent()); - assertTrue(specification.getResponseValidator().get().isEnabled()); + assertTrue(specification.isApiResponseValidationEnabled()); + assertTrue(specification.getSwaggerOpenApiValidationContext().isResponseValidationEnabled()); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java index a16ea69d09..59227c5cd7 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java @@ -16,55 +16,492 @@ package org.citrusframework.openapi; -import static org.mockito.Mockito.mock; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_DOUBLE; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_FLOAT; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_INT32; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_INT64; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_UUID; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_ARRAY; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_INTEGER; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_NUMBER; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_STRING; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; -import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; -import io.apicurio.datamodels.openapi.v2.models.Oas20Schema.Oas20AllOfSchema; +import com.atlassian.oai.validator.report.ValidationReport; +import com.atlassian.oai.validator.report.ValidationReport.Message; +import com.atlassian.oai.validator.schema.SchemaValidator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; -import java.util.HashMap; -import java.util.List; +import io.swagger.v3.oas.models.media.Schema; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.citrusframework.context.TestContext; +import org.citrusframework.functions.DefaultFunctionRegistry; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.spi.Resources; +import org.testng.Assert; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; public class OpenApiTestDataGeneratorTest { + private static final TestContext testContext = new TestContext(); + + private static OpenApiSpecification openApiSpecification; + + private static SchemaValidator schemaValidator; + + @BeforeClass + public static void beforeClass() { + testContext.setFunctionRegistry(new DefaultFunctionRegistry()); + + openApiSpecification = OpenApiSpecification.from( + Resources.fromClasspath("org/citrusframework/openapi/ping/ping-api.yaml")); + schemaValidator = openApiSpecification.getSwaggerOpenApiValidationContext() + .getSchemaValidator(); + } + + @DataProvider(name = "findLeastSignificantDecimalPlace") + public static Object[][] findLeastSignificantDecimalPlace() { + return new Object[][]{ + {new BigDecimal("1234.5678"), 4}, + {new BigDecimal("123.567"), 3}, + {new BigDecimal("123.56"), 2}, + {new BigDecimal("123.5"), 1}, + {new BigDecimal("123.0"), 0}, + {new BigDecimal("123"), 0} + }; + } + + @Test(dataProvider = "findLeastSignificantDecimalPlace") + void findLeastSignificantDecimalPlace(BigDecimal number, int expectedSignificance) { + assertEquals(OpenApiTestDataGenerator.findLeastSignificantDecimalPlace(number), + expectedSignificance); + } + + @DataProvider(name = "incrementToExclude") + public static Object[][] incrementToExclude() { + return new Object[][]{ + {new BigDecimal("1234.678"), new BigDecimal("1234.679")}, + {new BigDecimal("1234.78"), new BigDecimal("1234.79")}, + {new BigDecimal("1234.8"), new BigDecimal("1234.9")}, + {new BigDecimal("1234.0"), new BigDecimal("1235")}, + {new BigDecimal("1234"), new BigDecimal("1235")}, + }; + } + + @Test(dataProvider = "incrementToExclude") + void incrementToExclude(BigDecimal value, BigDecimal expectedValue) { + assertEquals(OpenApiTestDataGenerator.incrementToExclude(value), expectedValue); + } + + @DataProvider(name = "decrementToExclude") + public static Object[][] decrementToExclude() { + return new Object[][]{ + {new BigDecimal("1234.678"), new BigDecimal("1234.677")}, + {new BigDecimal("1234.78"), new BigDecimal("1234.77")}, + {new BigDecimal("1234.8"), new BigDecimal("1234.7")}, + {new BigDecimal("1234.0"), new BigDecimal("1233")}, + {new BigDecimal("1234"), new BigDecimal("1233")}, + }; + } + + @Test(dataProvider = "decrementToExclude") + void decrementToExclude(BigDecimal value, BigDecimal expectedValue) { + assertEquals(OpenApiTestDataGenerator.decrementToExclude(value), expectedValue); + } + @Test - public void anyOfIsIgnoredForOas3() { + void testUuidFormat() { + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.format = FORMAT_UUID; + + String uuidRandomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema, + false); + String finalUuidRandomValue = testContext.replaceDynamicContentInString(uuidRandomValue); + Pattern uuidPattern = Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + + assertTrue(uuidPattern.matcher(finalUuidRandomValue).matches()); + } + + @DataProvider(name = "testRandomNumber") + public static Object[][] testRandomNumber() { + return new Object[][]{ + {TYPE_INTEGER, FORMAT_INT32, null, 0, 2, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 2, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, -2, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, -2, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 11, 0, 12, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 12, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 13, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 14, 0, 14, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 15, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 16, -16, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 17, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 18, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 19, -20, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 20, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 21, 21, 21, false, false}, + + {TYPE_INTEGER, FORMAT_INT64, null, 0, 2, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 2, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, -2, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, -2, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 11, 0, 12, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 12, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 13, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 14, 0, 14, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 15, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 16, -16, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 17, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 18, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 19, -20, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 20, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 21, 21, 21, false, false}, + + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 2, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, null, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 2, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -2, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -2, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 11.123f, 0, 13, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 12.123f, null, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 13.123f, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 14.123f, 0, 14, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 15.123f, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 16.123f, -16, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 17.123f, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 18.123f, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 19.123f, -21, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 20.123f, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 21.123f, 21.122f, 21.124f, false, false}, - Oas30Schema anyOfSchema = new Oas30Schema(); - anyOfSchema.anyOf = List.of(new Oas30Schema(), new Oas30Schema()); + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 2, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, null, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 2, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -2, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -2, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 11.123d, 0, 13, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 12.123d, null, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 13.123d, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 14.123d, 0, 14, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 15.123d, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 16.123d, -16, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 17.123d, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 18.123d, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 19.123d, -21, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 20.123d, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 21.123d, 21.122d, 21.124d, false, false}, + }; + } + + @Test(dataProvider = "testRandomNumber") + void testRandomNumber(String type, String format, Number multipleOf, Number minimum, + Number maximum, boolean exclusiveMinimum, boolean exclusiveMaximum) { + Oas30Schema testSchema = new Oas30Schema(); + testSchema.type = type; + testSchema.format = format; + testSchema.multipleOf = multipleOf; + testSchema.minimum = minimum; + testSchema.maximum = maximum; + testSchema.exclusiveMinimum = exclusiveMinimum; + testSchema.exclusiveMaximum = exclusiveMaximum; + + try { + for (int i = 0; i < 1000; i++) { + String randomValue = OpenApiTestDataGenerator.createOutboundPayload( + testSchema, openApiSpecification); + String finalRandomValue = testContext.resolveDynamicValue(randomValue); + BigDecimal value = new BigDecimal(finalRandomValue); + + if (multipleOf != null) { + BigDecimal remainder = value.remainder(new BigDecimal(multipleOf.toString())); - assertEquals(OpenApiTestDataGenerator.createValidationExpression( - anyOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + assertEquals( + remainder.compareTo(BigDecimal.ZERO), 0, + "Expected %s to be a multiple of %s! Remainder is %s".formatted( + finalRandomValue, multipleOf, + remainder)); + } + + if (maximum != null) { + if (exclusiveMaximum) { + assertTrue(value.doubleValue() < testSchema.maximum.doubleValue(), + "Expected %s to be lower than %s!".formatted( + finalRandomValue, maximum)); + } else { + assertTrue(value.doubleValue() <= testSchema.maximum.doubleValue(), + "Expected %s to be lower or equal than %s!".formatted( + finalRandomValue, maximum)); + } + } + + if (minimum != null) { + if (exclusiveMinimum) { + assertTrue(value.doubleValue() > testSchema.minimum.doubleValue(), + "Expected %s to be larger than %s!".formatted( + finalRandomValue, minimum)); + } else { + assertTrue(value.doubleValue() >= testSchema.minimum.doubleValue(), + "Expected %s to be larger or equal than %s!".formatted( + finalRandomValue, minimum)); + } + } + } + } catch (Exception e) { + Assert.fail("Creation of multiple float threw an exception: " + e.getMessage(), e); + } } @Test - public void allOfIsIgnoredForOas3() { + void testPattern() { + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; - Oas30Schema allOfSchema = new Oas30Schema(); - allOfSchema.allOf = List.of(new Oas30Schema(), new Oas30Schema()); + String exp = "[0-3]([a-c]|[e-g]{1,2})"; + stringSchema.pattern = exp; - assertEquals(OpenApiTestDataGenerator.createValidationExpression( - allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + String randomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema, + false); + String finalRandomValue = testContext.replaceDynamicContentInString(randomValue); + assertTrue(finalRandomValue.matches(exp), + "Value '%s' does not match expression '%s'".formatted(finalRandomValue, exp)); + } + + @DataProvider(name = "testPingApiSchemas") + public static Object[][] testPingApiSchemas() { + return new Object[][]{ + + // Composites currently do not work properly - validation fails + //{"AnyOfType"}, + //{"AllOfType"}, + //{"PingRespType"}, + + {"OneOfType"}, + {"StringsType"}, + {"DatesType"}, + {"NumbersType"}, + {"MultipleOfType"}, + {"PingReqType"}, + {"Detail1"}, + {"Detail2"}, + {"BooleanType"}, + {"EnumType"}, + {"NestedType"}, + {"SimpleArrayType"}, + {"ComplexArrayType"}, + {"ArrayOfArraysType"}, + {"NullableType"}, + {"DefaultValueType"}, + }; + } + + + @Test(dataProvider = "testPingApiSchemas") + void testPingApiSchemas(String schemaType) throws IOException { + + OasSchema schema = OasModelHelper.getSchemaDefinitions( + openApiSpecification.getOpenApiDoc(null)).get(schemaType); + + Schema swaggerValidationSchema = openApiSpecification.getSwaggerOpenApiValidationContext() + .getSwaggerOpenApi().getComponents().getSchemas().get(schemaType); + + assertNotNull(schema); + + for (int i=0;i<100;i++) { + + String randomValue = OpenApiTestDataGenerator.createOutboundPayload(schema, + openApiSpecification); + assertNotNull(randomValue); + + String finalJsonAsText = testContext.replaceDynamicContentInString(randomValue); + + try { + JsonNode valueNode = new ObjectMapper().readTree( + testContext.replaceDynamicContentInString(finalJsonAsText)); + ValidationReport validationReport = schemaValidator.validate(() -> valueNode, + swaggerValidationSchema, + "response.body"); + + String message = """ + Json is invalid according to schema. + Message: %s + Report: %s + """.formatted(finalJsonAsText, validationReport.getMessages().stream().map( + Message::getMessage).collect(Collectors.joining("\n"))); + assertFalse(validationReport.hasErrors(), message); + } catch (JsonParseException e) { + Assert.fail("Unable to read generated schema to json: "+finalJsonAsText); + } + } } @Test - public void oneOfIsIgnoredForOas3() { + void testArray() { + Oas30Schema arraySchema = new Oas30Schema(); + arraySchema.type = TYPE_ARRAY; + + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.minLength = 5; + stringSchema.maxLength = 15; - Oas30Schema oneOfSchema = new Oas30Schema(); - oneOfSchema.oneOf = List.of(new Oas30Schema(), new Oas30Schema()); + arraySchema.items = stringSchema; - assertEquals(OpenApiTestDataGenerator.createValidationExpression( - oneOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + for (int i = 0; i < 10; i++) { + String randomValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, + openApiSpecification); + int nElements = StringUtils.countMatches(randomValue, "citrus:randomString"); + assertTrue(nElements > 0); + } } @Test - public void allOfIsIgnoredForOas2() { + void testArrayMinItems() { + Oas30Schema arraySchema = new Oas30Schema(); + arraySchema.type = TYPE_ARRAY; + arraySchema.minItems = 5; + + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.minLength = 5; + stringSchema.maxLength = 15; + + arraySchema.items = stringSchema; - Oas20AllOfSchema allOfSchema = new Oas20AllOfSchema(); - allOfSchema.allOf = List.of(new Oas20Schema(), new Oas20Schema()); + for (int i = 0; i < 10; i++) { + String randomValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, + openApiSpecification); + int nElements = StringUtils.countMatches(randomValue, "citrus:randomString(15)"); + assertTrue(nElements <= 5); + } + } + + @Test + void testArrayMaxItems() { + Oas30Schema arraySchema = new Oas30Schema(); + arraySchema.type = TYPE_ARRAY; + arraySchema.minItems = 2; + arraySchema.maxItems = 5; + + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.minLength = 10; + stringSchema.maxLength = 15; + + arraySchema.items = stringSchema; + + Pattern pattern = Pattern.compile("citrus:randomString\\(1[0-5]\\)"); + for (int i = 0; i < 100; i++) { + String randomArrayValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, + openApiSpecification); + + Matcher matcher = pattern.matcher(randomArrayValue); + int matches = 0; + while (matcher.find()) { + matches++; + } + + assertTrue(2 <= matches && matches <= 5, + "Expected random array string with number of elements between 2 and 4 but found %s: %s".formatted( + matches, randomArrayValue)); + } + } + + @Test + public void testLowestMultipleOf() { + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(10)), BigDecimal.valueOf(-1000)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(-10)), BigDecimal.valueOf(-1000)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(11)), BigDecimal.valueOf(-990)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(-11)), BigDecimal.valueOf(-990)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(11.1234)), BigDecimal.valueOf(-989.9826)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(-11.1234)), BigDecimal.valueOf(-989.9826)); + + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(10)), BigDecimal.valueOf(1000)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(-10)), BigDecimal.valueOf(1000)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(11)), BigDecimal.valueOf(1001)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(-11)), BigDecimal.valueOf(1001)); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(11.1234)), new BigDecimal("1001.1060")); + assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(-11.1234)), new BigDecimal("1001.1060")); + } + + @Test + public void testLargestMultipleOf() { + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(10)), BigDecimal.valueOf(-1000)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(-10)), BigDecimal.valueOf(-1000)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(11)), BigDecimal.valueOf(-1001)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(-11)), BigDecimal.valueOf(-1001)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(11.1234)), new BigDecimal("-1001.1060")); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), + BigDecimal.valueOf(-11.1234)), new BigDecimal("-1001.1060")); - assertEquals(OpenApiTestDataGenerator.createValidationExpression( - allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(10)), BigDecimal.valueOf(1000)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(-10)), BigDecimal.valueOf(1000)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(11)), BigDecimal.valueOf(990)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(-11)), BigDecimal.valueOf(990)); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(11.1234)), new BigDecimal("989.9826")); + assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), + BigDecimal.valueOf(-11.1234)), new BigDecimal("989.9826")); } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java new file mode 100644 index 0000000000..820a8cbbae --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java @@ -0,0 +1,71 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.citrusframework.openapi.OpenApiTestValidationDataGenerator.createValidationExpression; +import static org.mockito.Mockito.mock; +import static org.testng.Assert.assertEquals; + +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema.Oas20AllOfSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.HashMap; +import java.util.List; +import org.testng.annotations.Test; + +public class OpenApiTestValidationDataGeneratorTest { + + @Test + public void anyOfIsIgnoredForOas3() { + + Oas30Schema anyOfSchema = new Oas30Schema(); + anyOfSchema.anyOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(createValidationExpression( + anyOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void allOfIsIgnoredForOas3() { + + Oas30Schema allOfSchema = new Oas30Schema(); + allOfSchema.allOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(createValidationExpression( + allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void oneOfIsIgnoredForOas3() { + + Oas30Schema oneOfSchema = new Oas30Schema(); + oneOfSchema.oneOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(createValidationExpression( + oneOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void allOfIsIgnoredForOas2() { + + Oas20AllOfSchema allOfSchema = new Oas20AllOfSchema(); + allOfSchema.allOf = List.of(new Oas20Schema(), new Oas20Schema()); + + assertEquals(createValidationExpression( + allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java index 2c411b1179..9dbc709fa6 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java @@ -1,7 +1,24 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.util.OpenApiUtils; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.testng.annotations.AfterMethod; diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java index 574de92ccb..e33daf0770 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java @@ -16,6 +16,11 @@ package org.citrusframework.openapi.integration; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + import org.citrusframework.annotations.CitrusTest; import org.citrusframework.exceptions.TestCaseFailedException; import org.citrusframework.http.actions.HttpClientRequestActionBuilder.HttpMessageBuilderSupport; @@ -31,13 +36,10 @@ import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; import org.citrusframework.util.SocketUtils; import org.springframework.http.HttpStatus; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Ignore; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; -import static org.testng.Assert.assertThrows; -import static org.testng.Assert.fail; - /** * @author Christoph Deppisch */ @@ -61,25 +63,39 @@ public class OpenApiClientIT extends TestNGCitrusSpringSupport { .requestUrl("http://localhost:%d".formatted(port)) .build(); - /** - * Directly loaded open api. - */ private final OpenApiSpecification petstoreSpec = OpenApiSpecification.from( Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); + private final OpenApiSpecification pingSpec = OpenApiSpecification.from( + Resources.create("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + @CitrusTest @Test public void shouldExecuteGetPetByIdFromDirectSpec() { - shouldExecuteGetPetById(openapi(petstoreSpec), VALID_PET_PATH, true); + shouldExecuteGetPetById(openapi(petstoreSpec), VALID_PET_PATH, true, false); + } + + @CitrusTest + @Test + public void shouldFailOnMissingNameInResponse() { + shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, false, false); + } + + @CitrusTest + @Test + public void shouldSucceedOnMissingNameInResponseWithValidationDisabled() { + shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, true, true); } - private void shouldExecuteGetPetById(OpenApiActionBuilder openapi, String responseFile, boolean valid) { + private void shouldExecuteGetPetById(OpenApiActionBuilder openapi, String responseFile, + boolean valid, boolean disableValidation) { variable("petId", "1001"); when(openapi .client(httpClient) .send("getPetById") + .message() .fork(true)); then(http().server(httpServer) @@ -96,7 +112,9 @@ private void shouldExecuteGetPetById(OpenApiActionBuilder openapi, String respon .contentType("application/json")); OpenApiClientResponseActionBuilder clientResponseActionBuilder = openapi - .client(httpClient).receive("getPetById", HttpStatus.OK); + .client(httpClient).receive("getPetById", HttpStatus.OK) + .disableOasValidation(disableValidation); + if (valid) { then(clientResponseActionBuilder); } else { @@ -116,12 +134,6 @@ public void shouldProperlyExecuteGetAndAddPetFromRepository() { shouldExecuteGetAndAddPet(openapi(petstoreSpec)); } - @CitrusTest - @Test - public void shouldFailOnMissingNameInResponse() { - shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, false); - } - @CitrusTest @Test public void shouldFailOnMissingNameInRequest() { @@ -205,4 +217,37 @@ private void shouldExecuteGetAndAddPet(OpenApiActionBuilder openapi) { .client(httpClient) .receive("addPet", HttpStatus.CREATED)); } + + @DataProvider(name="pingApiOperationDataprovider") + public static Object[][] pingApiOperationDataprovider() { + return new Object[][]{{"doPing"}, {"doPong"}, {"doPung"}}; + } + + @Test(dataProvider = "pingApiOperationDataprovider") + @CitrusTest + @Ignore // Solve issue with composite schemes + public void shouldPerformRoundtripPingOperation(String pingApiOperation) { + + variable("id", 2001); + when(openapi(pingSpec) + .client(httpClient) + .send(pingApiOperation) + .message() + .fork(true)); + + then(openapi(pingSpec).server(httpServer) + .receive(pingApiOperation) + .message() + .accept("@contains('application/json')@")); + + then(openapi(pingSpec).server(httpServer) + .send(pingApiOperation) + .message() + .contentType("application/json")); + + OpenApiClientResponseActionBuilder clientResponseActionBuilder = openapi(pingSpec) + .client(httpClient).receive(pingApiOperation, HttpStatus.OK); + + then(clientResponseActionBuilder); + } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java index ede53a1e8b..662276d059 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java @@ -16,8 +16,14 @@ package org.citrusframework.openapi.integration; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + import org.citrusframework.annotations.CitrusTest; import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder.HttpMessageBuilderSupport; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpClientBuilder; import org.citrusframework.http.server.HttpServer; @@ -33,11 +39,6 @@ import org.springframework.http.HttpStatus; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; -import static org.testng.Assert.assertThrows; -import static org.testng.Assert.fail; - /** * @author Christoph Deppisch */ @@ -70,11 +71,6 @@ public class OpenApiServerIT extends TestNGCitrusSpringSupport { @CitrusTest public void shouldExecuteGetPetById() { - shouldExecuteGetPetById(openapi(petstoreSpec)); - } - - - private void shouldExecuteGetPetById(OpenApiActionBuilder openapi) { variable("petId", "1001"); when(http() @@ -85,11 +81,11 @@ private void shouldExecuteGetPetById(OpenApiActionBuilder openapi) { .accept("application/json") .fork(true)); - then(openapi + then(openapi(petstoreSpec) .server(httpServer) .receive("getPetById")); - then(openapi + then(openapi(petstoreSpec) .server(httpServer) .send("getPetById", HttpStatus.OK)); @@ -113,6 +109,96 @@ private void shouldExecuteGetPetById(OpenApiActionBuilder openapi) { """)); } + @CitrusTest + public void executeGetPetByIdShouldFailOnInvalidResponse() { + variable("petId", "1001"); + + when(http() + .client(httpClient) + .send() + .get("/pet/${petId}") + .message() + .accept("application/json") + .fork(true)); + + then(openapi(petstoreSpec) + .server(httpServer) + .receive("getPetById")); + + HttpMessageBuilderSupport getPetByIdResponseBuilder = openapi(petstoreSpec) + .server(httpServer) + .send("getPetById", HttpStatus.OK) + .message().body(""" + { + "id": "xxxx", + "name": "Garfield", + "category": { + "id": 111, + "name": "Comic" + }, + "photoUrls": [], + "tags": [], + "status": "available" + } + """); + assertThrows(TestCaseFailedException.class, () ->then(getPetByIdResponseBuilder)); + } + + @CitrusTest + public void executeGetPetByIdShouldSucceedOnInvalidResponseWithValidationDisabled() { + variable("petId", "1001"); + + when(http() + .client(httpClient) + .send() + .get("/pet/${petId}") + .message() + .accept("application/json") + .fork(true)); + + then(openapi(petstoreSpec) + .server(httpServer) + .receive("getPetById")); + + HttpMessageBuilderSupport getPetByIdResponseBuilder = openapi(petstoreSpec) + .server(httpServer) + .send("getPetById", HttpStatus.OK) + .disableOasValidation(true) + .message().body(""" + { + "id": "xxxx", + "name": "Garfield", + "category": { + "id": 111, + "name": "Comic" + }, + "photoUrls": [], + "tags": [], + "status": "available" + } + """); + then(getPetByIdResponseBuilder); + + then(http() + .client(httpClient) + .receive() + .response(HttpStatus.OK) + .message() + .body(""" + { + "id": "xxxx", + "name": "Garfield", + "category": { + "id": 111, + "name": "Comic" + }, + "photoUrls": [], + "tags": [], + "status": "available" + } + """)); + } + @CitrusTest public void shouldExecuteAddPet() { shouldExecuteAddPet(openapi(petstoreSpec), VALID_PET_PATH, true); @@ -170,7 +256,7 @@ public void shouldFailOnWrongQueryIdTypeWithOasDisabled() { @CitrusTest public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { - variable("petId", "xxx"); + variable("petId", -1); when(http() .client(httpClient) @@ -184,7 +270,7 @@ public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { OpenApiServerRequestActionBuilder addPetBuilder = openapi(petstoreSpec) .server(httpServer) .receive("addPet") - .disableOasValidation(true); + .disableOasValidation(false); try { when(addPetBuilder); @@ -223,4 +309,5 @@ private void shouldExecuteAddPet(OpenApiActionBuilder openapi, String requestFil .receive() .response(HttpStatus.CREATED)); } + } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java index 7846daf42b..d24101fb35 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java @@ -1,7 +1,23 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi.model; import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; -import org.citrusframework.openapi.OpenApiUtils; +import org.citrusframework.openapi.util.OpenApiUtils; import org.testng.annotations.Test; import static java.lang.String.format; diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java new file mode 100644 index 0000000000..2add7086c8 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java @@ -0,0 +1,89 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.util; + +import static org.testng.Assert.assertEquals; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomElementTest { + + private RandomElement.RandomList randomList; + private RandomElement.RandomObject randomObject; + private RandomElement.RandomValue randomValue; + + @BeforeMethod + public void setUp() { + randomList = new RandomElement.RandomList(); + randomObject = new RandomElement.RandomObject(); + randomValue = new RandomElement.RandomValue(); + } + + @Test + public void testRandomListPushValue() { + randomList.push("testValue"); + assertEquals(randomList.size(), 1); + assertEquals(randomList.get(0), "testValue"); + } + + @Test + public void testRandomListPushKeyValue() { + randomList.push(new RandomElement.RandomObject()); + randomList.push("key", "value"); + assertEquals(((RandomElement.RandomObject) randomList.get(0)).get("key"), "value"); + } + + @Test + public void testRandomObjectPushKeyValue() { + randomObject.push("key", "value"); + assertEquals(randomObject.get("key"), "value"); + } + + @Test + public void testRandomObjectPushRandomObject() { + RandomElement.RandomObject nestedObject = new RandomElement.RandomObject(); + nestedObject.push("nestedKey", "nestedValue"); + randomObject.push(nestedObject); + assertEquals(randomObject.size(), 1); + assertEquals(randomObject.get("nestedKey"), "nestedValue"); + } + + @Test(expectedExceptions = UnsupportedOperationException.class) + public void testRandomObjectPushValueThrowsException() { + randomObject.push("value"); + } + + @Test + public void testRandomValuePushValue() { + randomValue.push("testValue"); + assertEquals(randomValue.getValue(), "testValue"); + } + + @Test + public void testRandomValuePushRandomElement() { + RandomElement.RandomObject nestedObject = new RandomElement.RandomObject(); + randomValue = new RandomElement.RandomValue(nestedObject); + randomValue.push("key", "value"); + assertEquals(((RandomElement.RandomObject) randomValue.getValue()).get("key"), "value"); + } + + @Test(expectedExceptions = IllegalStateException.class) + public void testRandomValuePushKeyValueThrowsException() { + randomValue.push("key", "value"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java new file mode 100644 index 0000000000..f7570c2083 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java @@ -0,0 +1,127 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.util; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +public class RandomModelBuilderTest { + + private RandomModelBuilder builder; + + @BeforeMethod + public void setUp() { + builder = new RandomModelBuilder(); + } + + @Test + public void testInitialState() { + String text = builder.toString(); + assertEquals(text, ""); + } + + @Test + public void testAppendSimple() { + builder.appendSimple("testValue"); + String json = builder.toString(); + assertEquals(json, "testValue"); + } + + @Test + public void testObjectWithProperties() { + builder.object(() -> { + builder.property("key1", () -> builder.appendSimple("\"value1\"")); + builder.property("key2", () -> builder.appendSimple("\"value2\"")); + }); + String json = builder.toString(); + assertEquals(json, "{\"key1\": \"value1\",\"key2\": \"value2\"}"); + } + + @Test + public void testNestedObject() { + builder.object(() -> + builder.property("outerKey", () -> builder.object(() -> + builder.property("innerKey", () -> builder.appendSimple("\"innerValue\"")) + )) + ); + String json = builder.toString(); + assertEquals(json, "{\"outerKey\": {\"innerKey\": \"innerValue\"}}"); + } + + @Test + public void testArray() { + builder.array(() -> { + builder.appendSimple("\"value1\""); + builder.appendSimple("\"value2\""); + builder.appendSimple("\"value3\""); + }); + String json = builder.toString(); + assertEquals(json, "[\"value1\",\"value2\",\"value3\"]"); + } + + @Test + public void testNestedArray() { + builder.array(() -> { + builder.appendSimple("\"value1\""); + builder.array(() -> { + builder.appendSimple("\"nestedValue1\""); + builder.appendSimple("\"nestedValue2\""); + }); + builder.appendSimple("\"value2\""); + }); + String json = builder.toString(); + assertEquals(json, "[\"value1\",[\"nestedValue1\",\"nestedValue2\"],\"value2\"]"); + } + + @Test + public void testMixedStructure() { + builder.object(() -> { + builder.property("key1", () -> builder.array(() -> { + builder.appendSimple("\"value1\""); + builder.object(() -> + builder.property("nestedKey", () -> builder.appendSimple("\"nestedValue\"")) + ); + })); + builder.property("key2", () -> builder.appendSimple("\"value2\"")); + }); + String json = builder.toString(); + assertEquals(json, "{\"key1\": [\"value1\",{\"nestedKey\": \"nestedValue\"}],\"key2\": \"value2\"}"); + } + + @Test + public void testIllegalStateOnEmptyDeque() { + + builder.deque.clear(); + + Exception exception = expectThrows(IllegalStateException.class, () -> + builder.property("key", () -> builder.appendSimple("value")) + ); + assertEquals(exception.getMessage(), "Encountered empty stack!"); + + exception = expectThrows(IllegalStateException.class, () -> + builder.object(() -> {}) + ); + assertEquals(exception.getMessage(), "Encountered empty stack!"); + + exception = expectThrows(IllegalStateException.class, () -> + builder.array(() -> {}) + ); + assertEquals(exception.getMessage(), "Encountered empty stack!"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java index 7c7a578106..80d901395d 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java @@ -1,39 +1,53 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi.validation; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertNotNull; + +import java.util.Optional; import org.citrusframework.context.TestContext; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - public class OpenApiRequestValidationProcessorTest { @Mock private OpenApiSpecification openApiSpecificationMock; - @Mock - private OpenApiRequestValidator requestValidatorMock; - @Mock private OperationPathAdapter operationPathAdapterMock; - @InjectMocks private OpenApiRequestValidationProcessor processor; private AutoCloseable mockCloseable; @@ -49,71 +63,61 @@ public void afterMethod() throws Exception { mockCloseable.close(); } - @Test - public void shouldNotValidateWhenDisabled() { - processor.setEnabled(false); - HttpMessage messageMock = mock(); - - processor.validate(messageMock, mock()); - - verify(openApiSpecificationMock, never()).getOperation(any(), any()); - } - @Test public void shouldNotValidateNonHttpMessage() { Message messageMock = mock(); processor.validate(messageMock, mock()); - verify(openApiSpecificationMock, never()).getOperation(any(), any()); + verify(openApiSpecificationMock,times(2)).getSwaggerOpenApiValidationContext(); + verifyNoMoreInteractions(openApiSpecificationMock); } @Test public void shouldValidateHttpMessage() { - processor.setEnabled(true); HttpMessage httpMessageMock = mock(); TestContext contextMock = mock(); + OpenApiRequestValidator openApiRequestValidatorSpy = replaceValidatorWithSpy(httpMessageMock); + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) .thenReturn(Optional.of(operationPathAdapterMock)); - when(openApiSpecificationMock.getRequestValidator()) - .thenReturn(Optional.of(requestValidatorMock)); processor.validate(httpMessageMock, contextMock); - verify(requestValidatorMock, times(1)).validateRequest(operationPathAdapterMock, httpMessageMock); + verify(openApiRequestValidatorSpy, times(1)).validateRequest(operationPathAdapterMock, httpMessageMock); } @Test - public void shouldNotValidateWhenNoOperation() { - processor.setEnabled(true); - HttpMessage httpMessage = mock(HttpMessage.class); - TestContext context = mock(TestContext.class); + public void shouldCallValidateRequest() { + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + OpenApiRequestValidator openApiRequestValidatorSpy = replaceValidatorWithSpy(httpMessageMock); when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) .thenReturn(Optional.empty()); - processor.validate(httpMessage, context); + processor.validate(httpMessageMock, contextMock); - verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); - verify(openApiSpecificationMock, never()).getRequestValidator(); + verify(openApiSpecificationMock, times(1)).getOperation(anyString(), + any(TestContext.class)); + verify(openApiRequestValidatorSpy, times(0)).validateRequest(operationPathAdapterMock, httpMessageMock); } - @Test - public void shouldNotValidateWhenNoValidator() { - processor.setEnabled(true); - HttpMessage httpMessage = mock(HttpMessage.class); - TestContext context = mock(TestContext.class); + private OpenApiRequestValidator replaceValidatorWithSpy(HttpMessage httpMessage) { + OpenApiRequestValidator openApiRequestValidator = (OpenApiRequestValidator) ReflectionTestUtils.getField( + processor, + "openApiRequestValidator"); - when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) - .thenReturn(Optional.of(operationPathAdapterMock)); - when(openApiSpecificationMock.getRequestValidator()) - .thenReturn(Optional.empty()); + assertNotNull(openApiRequestValidator); + OpenApiRequestValidator openApiRequestValidatorSpy = spy(openApiRequestValidator); + ReflectionTestUtils.setField(processor, "openApiRequestValidator", openApiRequestValidatorSpy); - processor.validate(httpMessage, context); + doAnswer((invocation) -> null + // do nothing + ).when(openApiRequestValidatorSpy).validateRequest(operationPathAdapterMock, httpMessage); - verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); - verify(openApiSpecificationMock, times(1)).getRequestValidator(); - verify(requestValidatorMock, never()).validateRequest(any(), any()); + return openApiRequestValidatorSpy; } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java index 9b97d42b78..f79a8a9887 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java @@ -1,14 +1,46 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi.validation; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request; import com.atlassian.oai.validator.model.Request.Method; import com.atlassian.oai.validator.report.ValidationReport; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.web.bind.annotation.RequestMethod; @@ -17,23 +49,13 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +public class OpenApiRequestValidatorTest { -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertTrue; + @Mock + private OpenApiSpecification openApiSpecificationMock; -public class OpenApiRequestValidatorTest { + @Mock + private SwaggerOpenApiValidationContext swaggerOpenApiValidationContextMock; @Mock private OpenApiInteractionValidator openApiInteractionValidatorMock; @@ -47,7 +69,6 @@ public class OpenApiRequestValidatorTest { @Mock private ValidationReport validationReportMock; - @InjectMocks private OpenApiRequestValidator openApiRequestValidator; private AutoCloseable mockCloseable; @@ -55,7 +76,11 @@ public class OpenApiRequestValidatorTest { @BeforeMethod public void beforeMethod() { mockCloseable = MockitoAnnotations.openMocks(this); - openApiRequestValidator = new OpenApiRequestValidator(openApiInteractionValidatorMock); + + doReturn(swaggerOpenApiValidationContextMock).when(openApiSpecificationMock).getSwaggerOpenApiValidationContext(); + doReturn(openApiInteractionValidatorMock).when(swaggerOpenApiValidationContextMock).getOpenApiInteractionValidator(); + + openApiRequestValidator = new OpenApiRequestValidator(openApiSpecificationMock); } @AfterMethod @@ -143,13 +168,4 @@ public void shouldCreateRequestFromMessage() throws IOException { assertEquals(request.getRequestBody().get().toString(StandardCharsets.UTF_8), "payload"); } - private Request callCreateRequestFromMessage(OpenApiRequestValidator validator, OperationPathAdapter adapter, HttpMessage message) { - try { - var method = OpenApiRequestValidator.class.getDeclaredMethod("createRequestFromMessage", OperationPathAdapter.class, HttpMessage.class); - method.setAccessible(true); - return (Request) method.invoke(validator, adapter, message); - } catch (Exception e) { - throw new RuntimeException(e); - } - } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java index bd60fd55b6..671560ba9f 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java @@ -1,39 +1,53 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi.validation; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertNotNull; + +import java.util.Optional; import org.citrusframework.context.TestContext; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.message.Message; import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - public class OpenApiResponseValidationProcessorTest { @Mock private OpenApiSpecification openApiSpecificationMock; - @Mock - private OpenApiResponseValidator responseValidatorMock; - @Mock private OperationPathAdapter operationPathAdapterMock; - @InjectMocks private OpenApiResponseValidationProcessor processor; private AutoCloseable mockCloseable; @@ -49,71 +63,61 @@ public void afterMethod() throws Exception { mockCloseable.close(); } - @Test - public void shouldNotValidateWhenDisabled() { - processor.setEnabled(false); - HttpMessage messageMock = mock(); - - processor.validate(messageMock, mock()); - - verify(openApiSpecificationMock, never()).getOperation(any(), any()); - } - @Test public void shouldNotValidateNonHttpMessage() { Message messageMock = mock(); processor.validate(messageMock, mock()); - verify(openApiSpecificationMock, never()).getOperation(any(), any()); + verify(openApiSpecificationMock,times(2)).getSwaggerOpenApiValidationContext(); + verifyNoMoreInteractions(openApiSpecificationMock); } @Test - public void shouldValidateHttpMessage() { - processor.setEnabled(true); + public void shouldCallValidateResponse() { HttpMessage httpMessageMock = mock(); TestContext contextMock = mock(); + OpenApiResponseValidator openApiResponseValidatorSpy = replaceValidatorWithSpy(httpMessageMock); + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) .thenReturn(Optional.of(operationPathAdapterMock)); - when(openApiSpecificationMock.getResponseValidator()) - .thenReturn(Optional.of(responseValidatorMock)); processor.validate(httpMessageMock, contextMock); - verify(responseValidatorMock, times(1)).validateResponse(operationPathAdapterMock, httpMessageMock); + verify(openApiResponseValidatorSpy, times(1)).validateResponse(operationPathAdapterMock, httpMessageMock); } @Test public void shouldNotValidateWhenNoOperation() { - processor.setEnabled(true); - HttpMessage httpMessage = mock(HttpMessage.class); - TestContext context = mock(TestContext.class); + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + OpenApiResponseValidator openApiResponseValidatorSpy = replaceValidatorWithSpy(httpMessageMock); when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) .thenReturn(Optional.empty()); - processor.validate(httpMessage, context); + processor.validate(httpMessageMock, contextMock); - verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); - verify(openApiSpecificationMock, never()).getResponseValidator(); + verify(openApiSpecificationMock, times(1)).getOperation(anyString(), + any(TestContext.class)); + verify(openApiResponseValidatorSpy, times(0)).validateResponse(operationPathAdapterMock, httpMessageMock); } - @Test - public void shouldNotValidateWhenNoValidator() { - processor.setEnabled(true); - HttpMessage httpMessage = mock(HttpMessage.class); - TestContext context = mock(TestContext.class); + private OpenApiResponseValidator replaceValidatorWithSpy(HttpMessage httpMessage) { + OpenApiResponseValidator openApiResponseValidator = (OpenApiResponseValidator) ReflectionTestUtils.getField( + processor, + "openApiResponseValidator"); - when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) - .thenReturn(Optional.of(operationPathAdapterMock)); - when(openApiSpecificationMock.getResponseValidator()) - .thenReturn(Optional.empty()); + assertNotNull(openApiResponseValidator); + OpenApiResponseValidator openApiResponseValidatorSpy = spy(openApiResponseValidator); + ReflectionTestUtils.setField(processor, "openApiResponseValidator", openApiResponseValidatorSpy); - processor.validate(httpMessage, context); + doAnswer((invocation) -> null + // do nothing + ).when(openApiResponseValidatorSpy).validateResponse(operationPathAdapterMock, httpMessage); - verify(openApiSpecificationMock, times(1)).getOperation(anyString(), any(TestContext.class)); - verify(openApiSpecificationMock, times(1)).getResponseValidator(); - verify(responseValidatorMock, never()).validateResponse(any(), any()); + return openApiResponseValidatorSpy; } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java index 5bfef2eacb..e59f1f2821 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java @@ -1,3 +1,19 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.citrusframework.openapi.validation; import com.atlassian.oai.validator.OpenApiInteractionValidator; @@ -7,6 +23,7 @@ import io.apicurio.datamodels.openapi.models.OasOperation; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.OpenApiSpecification; import org.citrusframework.openapi.model.OperationPathAdapter; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -23,15 +40,23 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; public class OpenApiResponseValidatorTest { + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private SwaggerOpenApiValidationContext swaggerOpenApiValidationContextMock; + @Mock private OpenApiInteractionValidator openApiInteractionValidatorMock; @@ -55,7 +80,11 @@ public class OpenApiResponseValidatorTest { @BeforeMethod public void beforeMethod() { mockCloseable = MockitoAnnotations.openMocks(this); - openApiResponseValidator = new OpenApiResponseValidator(openApiInteractionValidatorMock); + + doReturn(swaggerOpenApiValidationContextMock).when(openApiSpecificationMock).getSwaggerOpenApiValidationContext(); + doReturn(openApiInteractionValidatorMock).when(swaggerOpenApiValidationContextMock).getOpenApiInteractionValidator(); + + openApiResponseValidator = new OpenApiResponseValidator(openApiSpecificationMock); } @AfterMethod @@ -128,7 +157,9 @@ public void shouldCreateResponseMessage() throws IOException { // Then assertNotNull(response); + assertTrue(response.getResponseBody().isPresent()); assertEquals(response.getResponseBody().get().toString(StandardCharsets.UTF_8), "payload"); + assertTrue(response.getHeaderValue("Content-Type").isPresent()); assertEquals(response.getHeaderValue("Content-Type").get(), "application/json"); assertEquals(response.getStatus(), Integer.valueOf(200)); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java index d8ac404c2c..500a559eeb 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java @@ -16,6 +16,12 @@ package org.citrusframework.openapi.xml; +import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; + +import java.io.IOException; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -55,13 +61,6 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; -import java.io.IOException; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ArrayBlockingQueue; - -import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; - /** * @author Christoph Deppisch */ @@ -252,4 +251,5 @@ public void shouldLookupTestActionBuilder() { Assert.assertTrue(XmlTestActionBuilder.lookup("openapi").isPresent()); Assert.assertEquals(XmlTestActionBuilder.lookup("openapi").get().getClass(), OpenApi.class); } + } diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json index 618854948f..a7e135c535 100644 --- a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json @@ -98,7 +98,8 @@ "description": "ID of pet to return", "schema": { "format": "int64", - "type": "integer" + "type": "integer", + "minimum": 1 }, "in": "path", "required": true @@ -158,7 +159,8 @@ "description": "Pet id to delete", "schema": { "format": "int64", - "type": "integer" + "type": "integer", + "minimum": 1 }, "in": "path", "required": true diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml index f5e8f95b1a..29bda14fb2 100644 --- a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml @@ -84,24 +84,33 @@ paths: responses: 200: description: successful operation without a response + /pung/{id}: + get: + tags: + - pong + summary: Do the pung + operationId: doPung + parameters: + - name: id + in: path + description: Id to pung + required: true + explode: true + schema: + type: integer + format: int64 + responses: + 200: + description: successful pung operation with all types + content: + application/json: + schema: + $ref: '#/components/schemas/StringsType' + plain/text: + schema: + type: string components: schemas: - Ipv6Type: - required: - - ipv6 - type: object - properties: - ipv6: - type: string - format: ipv6 - Ipv4Type: - required: - - ipv4 - type: object - properties: - ipv4: - type: string - format: ipv4 DateType: required: - date @@ -118,98 +127,207 @@ components: dateTime: type: string format: date-time - EmailType: - required: - - email + AllOfType: + allOf: + - $ref: '#/components/schemas/NumbersType' + - $ref: '#/components/schemas/StringsType' + - $ref: '#/components/schemas/MultipleOfType' + - $ref: '#/components/schemas/DatesType' + discriminator: + propertyName: type + mapping: + NumbersType: '#/components/schemas/NumbersType' + StringsType: '#/components/schemas/StringsType' + MultipleOfType: '#/components/schemas/MultipleOfType' + DatesType: '#/components/schemas/DatesType' + AnyOfType: + anyOf: + - $ref: '#/components/schemas/NumbersType' + - $ref: '#/components/schemas/StringsType' + - $ref: '#/components/schemas/MultipleOfType' + - $ref: '#/components/schemas/DatesType' + discriminator: + propertyName: type + mapping: + NumbersType: '#/components/schemas/NumbersType' + StringsType: '#/components/schemas/StringsType' + MultipleOfType: '#/components/schemas/MultipleOfType' + DatesType: '#/components/schemas/DatesType' + OneOfType: + oneOf: + - $ref: '#/components/schemas/NumbersType' + - $ref: '#/components/schemas/StringsType' + - $ref: '#/components/schemas/MultipleOfType' + - $ref: '#/components/schemas/DatesType' + discriminator: + propertyName: type + mapping: + NumbersType: '#/components/schemas/NumbersType' + StringsType: '#/components/schemas/StringsType' + MultipleOfType: '#/components/schemas/MultipleOfType' + DatesType: '#/components/schemas/DatesType' + MultipleOfType: type: object - properties: - email: - type: string - format: email - ByteType: required: - - byte - type: object + - type + - manyPi + - even properties: - byte: + type: type: string - format: byte - BinaryType: - required: - - binary + enum: [ MultiplesType ] + manyPi: + type: number + format: double + multipleOf: 3.14159 + minimum: 0 + maximum: 31459 + even: + type: integer + format: int32 + multipleOf: 2 + minimum: -2000 + maximum: 2000 + StringsType: type: object - properties: - binary: - type: string - format: binary - UriType: required: - - uri - type: object + - type properties: - uri: + type: type: string - format: uri - UriReferenceType: - required: - - uriReference - type: object - properties: - uriReference: + enum: [ StringsType ] + smallString: type: string - format: uri-refence - HostnameType: - required: - - hostname + minLength: 0 + maxLength: 10 + mediumString: + type: string + minLength: 0 + maxLength: 256 + largeString: + type: string + minLength: 0 + maxLength: 1024 + nonEmptyString: + type: string + minLength: 256 + maxLength: 512 + NumbersType: type: object + required: + - type + - integerInt32 + - integerInt64 + - numberFloat + - numberDouble + - positiveIntegerInt32 + - negativeIntegerInt64 + - positiveNumberFloat + - negativeNumberDouble + - betweenIntegerInt32 + - betweenIntegerInt64 + - betweenNumberFloat + - betweenNumberDouble + - betweenIntegerInt32Exclude + - betweenIntegerInt64Exclude + - betweenNumberFloatExclude + - betweenNumberDoubleExclude properties: - hostname: + type: type: string - format: hostname - AllTypes: + enum: [ NumbersType ] + integerInt32: + type: integer + format: int32 + integerInt64: + type: integer + format: int64 + numberFloat: + type: number + format: float + numberDouble: + type: number + format: double + positiveIntegerInt32: + type: integer + format: int32 + minimum: 0 + negativeIntegerInt64: + type: integer + format: int64 + maximum: 0 + positiveNumberFloat: + type: number + format: float + minimum: 0 + negativeNumberDouble: + type: number + format: double + maximum: 0 + betweenIntegerInt32: + type: integer + format: int32 + minimum: 2 + maximum: 8 + betweenIntegerInt64: + type: integer + format: int64 + minimum: 2 + maximum: 3 + betweenNumberFloat: + type: number + format: float + minimum: 2 + maximum: 3 + betweenNumberDouble: + type: number + format: double + minimum: 2 + maximum: 3 + betweenIntegerInt32Exclude: + type: integer + format: int32 + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + betweenIntegerInt64Exclude: + type: integer + format: int64 + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + betweenNumberFloatExclude: + type: number + format: float + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + betweenNumberDoubleExclude: + type: number + format: double + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + DatesType: required: - - email - - ipv6 - - ipv4 + - type - date - dateTime - - binary - - byte - - uri - - uriReference - - hostname type: object properties: - ipv6: + type: type: string - format: ipv6 - ipv4: - type: string - format: ipv4 + enum: [ DatesType ] date: type: string format: date dateTime: type: string format: date-time - email: - type: string - format: email - binary: - type: string - format: binary - byte: - type: string - format: byte - uri: - type: string - format: uri - uriReference: - type: string - format: uri-reference - hostname: - type: string - format: hostname PingReqType: type: object properties: @@ -218,21 +336,34 @@ components: format: int64 Detail1: type: object + required: + - type properties: - host: - $ref: '#/components/schemas/HostnameType' - uri: - $ref: '#/components/schemas/UriType' + type: + type: string + enum: [ Detail1Type ] + allTypes: + $ref: '#/components/schemas/NumbersType' Detail2: type: object + required: + - type properties: - ipv4: - $ref: '#/components/schemas/Ipv4Type' - uriReference: - $ref: '#/components/schemas/UriReferenceType' + type: + type: string + enum: [ Detail2Type ] + allString: + $ref: '#/components/schemas/StringsType' + allDates: + $ref: '#/components/schemas/DatesType' PingRespType: type: object + required: + - type properties: + type: + type: string + enum: [ PingRespType ] id: type: integer format: int64 @@ -242,3 +373,95 @@ components: anyOf: - $ref: '#/components/schemas/Detail1' - $ref: '#/components/schemas/Detail2' + discriminator: + propertyName: type + mapping: + Detail1Type: '#/components/schemas/Detail1' + Detail2Type: '#/components/schemas/Detail2' + BooleanType: + type: object + required: + - isActive + - isVerified + properties: + isActive: + type: boolean + isVerified: + type: boolean + EnumType: + type: object + required: + - status + properties: + status: + type: string + enum: + - ACTIVE + - INACTIVE + - PENDING + NestedType: + type: object + properties: + id: + type: integer + format: int64 + details: + $ref: '#/components/schemas/Detail1' + SimpleArrayType: + type: object + properties: + stringItems: + type: array + items: + type: string + minLength: 2 + maxLength: 5 + minItems: 10 + maxItems: 20 + numberItems: + type: array + items: + type: integer + minItems: 10 + maxItems: 20 + booleanItems: + type: array + items: + type: boolean + dateItems: + type: array + items: + type: string + format: date + ComplexArrayType: + type: object + properties: + stringItems: + type: array + items: + $ref: '#/components/schemas/StringsType' + numberItems: + type: array + items: + $ref: '#/components/schemas/NumbersType' + ArrayOfArraysType: + type: object + properties: + matrix: + type: array + items: + type: array + items: + type: integer + NullableType: + type: object + properties: + nullableString: + type: string + nullable: true + DefaultValueType: + type: object + properties: + defaultValue: + type: string + default: "defaultValue" \ No newline at end of file diff --git a/core/citrus-base/pom.xml b/core/citrus-base/pom.xml index 3dd7675b7e..8f4f8991bc 100644 --- a/core/citrus-base/pom.xml +++ b/core/citrus-base/pom.xml @@ -28,12 +28,16 @@ commons-codec commons-codec - jakarta.xml.bind jakarta.xml.bind-api provided + + com.github.mifmif + generex + 1.0.2 + diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java index 6111a9d9c3..5441e3f13f 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java @@ -31,8 +31,10 @@ import org.citrusframework.functions.core.LowerCaseFunction; import org.citrusframework.functions.core.MaxFunction; import org.citrusframework.functions.core.MinFunction; +import org.citrusframework.functions.core.AdvancedRandomNumberFunction; import org.citrusframework.functions.core.RandomEnumValueFunction; import org.citrusframework.functions.core.RandomNumberFunction; +import org.citrusframework.functions.core.RandomPatternFunction; import org.citrusframework.functions.core.RandomStringFunction; import org.citrusframework.functions.core.RandomUUIDFunction; import org.citrusframework.functions.core.ReadFileResourceFunction; @@ -66,7 +68,9 @@ public DefaultFunctionLibrary() { setName("citrusFunctionLibrary"); getMembers().put("randomNumber", new RandomNumberFunction()); + getMembers().put("randomNumberGenerator", new AdvancedRandomNumberFunction()); getMembers().put("randomString", new RandomStringFunction()); + getMembers().put("randomValue", new RandomPatternFunction()); getMembers().put("concat", new ConcatFunction()); getMembers().put("currentDate", new CurrentDateFunction()); getMembers().put("substring", new SubstringFunction()); diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java new file mode 100644 index 0000000000..bc7252e057 --- /dev/null +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java @@ -0,0 +1,144 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.functions.core; + +import static java.lang.String.format; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Random; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.InvalidFunctionUsageException; +import org.citrusframework.functions.Function; + +/** + * A function for generating random double values with specified decimal places and range. This + * function includes options to specify the number of decimal places, minimum and maximum values, + * and whether to include or exclude the minimum and maximum values. + *

+ * Parameters: + *

    + *
  1. Decimal places: The number of decimal places in the generated random number (optional, default: 0). Note that definition of 0 results in an integer.
  2. + *
  3. Min value: The minimum value for the generated random number (optional, default: Double.MIN_VALUE).
  4. + *
  5. Max value: The maximum value for the generated random number (optional, default: Double.MAX_VALUE).
  6. + *
  7. Exclude min: Whether to exclude the minimum value (optional, default: false).
  8. + *
  9. Exclude man: Whether to exclude the maximum value (optional, default: false).
  10. + *
+ *

+ * This function differs from the {@link RandomNumberFunction} in several key ways: + *

    + *
  • It allows to specify several aspects of a number (see above).
  • + *
  • The length of the number is restricted to the range and precision of a double, whereas RandomNumberFunction can create arbitrarily long integer values.
  • + *
+ */ +public class AdvancedRandomNumberFunction implements Function { + + /** + * Basic seed generating random number + */ + private static final Random generator = new Random(System.currentTimeMillis()); + + public String execute(List parameterList, TestContext context) { + int decimalPlaces = 0; + double minValue = -1000000; + double maxValue = 1000000; + boolean excludeMin = false; + boolean excludeMax = false; + + if (parameterList == null) { + throw new InvalidFunctionUsageException("Function parameters must not be null."); + } + + if (!parameterList.isEmpty()) { + decimalPlaces = parseParameter(1, parameterList.get(0), Integer.class, + Integer::parseInt); + if (decimalPlaces < 0) { + throw new InvalidFunctionUsageException( + "Invalid parameter definition. Decimal places must be a non-negative integer value."); + } + } + + if (parameterList.size() > 1) { + minValue = parseParameter(2, parameterList.get(1), Double.class, Double::parseDouble); + } + + if (parameterList.size() > 2) { + maxValue = parseParameter(3, parameterList.get(2), Double.class, Double::parseDouble); + if (minValue > maxValue) { + throw new InvalidFunctionUsageException( + "Invalid parameter definition. Min value must be less than max value."); + } + } + + if (parameterList.size() > 3) { + excludeMin = parseParameter(4, parameterList.get(3), Boolean.class, + Boolean::parseBoolean); + } + + if (parameterList.size() > 4) { + excludeMax = parseParameter(5, parameterList.get(4), Boolean.class, + Boolean::parseBoolean); + } + + return getRandomNumber(decimalPlaces, minValue, maxValue, excludeMin, excludeMax); + } + + private T parseParameter(int index, String text, Class type, + java.util.function.Function parseFunction) { + try { + return parseFunction.apply(text); + } catch (Exception e) { + throw new InvalidFunctionUsageException( + format("Invalid parameter at index %d. %s must be parsable to %s.", index, text, + type.getSimpleName())); + } + } + + /** + * Static number generator method. + */ + private String getRandomNumber(int decimalPlaces, double minValue, double maxValue, + boolean excludeMin, boolean excludeMax) { + double adjustment = Math.pow(10, -decimalPlaces); + + if (excludeMin) { + minValue += adjustment; + } + + if (excludeMax) { + maxValue -= adjustment; + } + + BigDecimal range = BigDecimal.valueOf(maxValue).subtract(BigDecimal.valueOf(minValue)); + + double randomValue = getRandomValue(minValue, range, generator.nextDouble()); + BigDecimal bd = new BigDecimal(Double.toString(randomValue)); + bd = bd.setScale(2, RoundingMode.HALF_UP); + + return decimalPlaces == 0 ? + format("%s", bd.longValue()) : + format(format("%%.%sf", decimalPlaces), bd.doubleValue()); + } + + double getRandomValue(double minValue, BigDecimal range, double random) { + BigDecimal offset = range.multiply(BigDecimal.valueOf(random)); + BigDecimal value = BigDecimal.valueOf(minValue).add(offset); + return value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0 ? Double.MAX_VALUE : value.doubleValue(); + } + +} \ No newline at end of file diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java new file mode 100644 index 0000000000..4720921c7e --- /dev/null +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java @@ -0,0 +1,60 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.functions.core; + +import static org.citrusframework.util.StringUtils.hasText; + +import com.mifmif.common.regex.Generex; +import java.util.List; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.InvalidFunctionUsageException; +import org.citrusframework.functions.Function; + +/** + * The RandomPatternFunction class implements the Function interface. This function generates a + * random string based on a provided regular expression pattern. It uses the Generex library to + * generate the random string. + *

+ * Note: The Generex library has limitations in its ability to generate all possible expressions + * from a given regular expression. It may not support certain complex regex features or produce all + * possible variations. + */ +public class RandomPatternFunction implements Function { + + + public String execute(List parameterList, TestContext context) { + + if (parameterList == null) { + throw new InvalidFunctionUsageException("Function parameters must not be null."); + } + + String pattern = parameterList.get(0); + + if (!hasText(pattern)) { + throw new InvalidFunctionUsageException("Pattern must not be empty."); + } + + if (!Generex.isValidPattern(pattern)) { + throw new IllegalArgumentException( + "Function called with a pattern, the algorithm is not able to create a string for."); + } + + Generex generex = new Generex(pattern); + return generex.random(); + } + +} \ No newline at end of file diff --git a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java index b740f82e27..ba715afe48 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java +++ b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java @@ -65,4 +65,31 @@ public static String appendSegmentToUrlPath(String path, String segment) { return path + segment; } + + public static String quote(String text, boolean quote) { + return quote ? String.format("\"%s\"", text) : text; + } + + /** + * Trims trailing whitespace characters and the first trailing comma from the end of the given StringBuilder. + * + * This method removes all trailing whitespace characters (such as spaces, tabs, and newline characters) + * and the first trailing comma found from the end of the content in the provided StringBuilder. + * Any additional commas or whitespace characters after the first trailing comma are not removed. + * + * @param builder the StringBuilder whose trailing whitespace characters and first comma are to be removed + */ + public static void trimTrailingComma(StringBuilder builder) { + int length = builder.length(); + while (length > 0 && (builder.charAt(length - 1) == ',' || Character.isWhitespace(builder.charAt(length - 1)))) { + char c = builder.charAt(length - 1); + builder.deleteCharAt(length - 1); + + if (c == ',') { + return; + } + + length = builder.length(); + } + } } diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java new file mode 100644 index 0000000000..1452fb881b --- /dev/null +++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java @@ -0,0 +1,246 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.functions.core; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.InvalidFunctionUsageException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomDoubleFunctionTest { + + private AdvancedRandomNumberFunction function; + private TestContext context; + + @BeforeMethod + public void setUp() { + function = new AdvancedRandomNumberFunction(); + context = new TestContext(); + } + + @Test + public void testRandomNumberWithNullParameter() { + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(null, context)); + assertEquals(exception.getMessage(), + "Function parameters must not be null."); + } + + @Test + public void testRandomNumberWithDefaultValues() { + List params = List.of(); + String result = function.execute(params, context); + assertNotNull(result); + assertTrue(result.matches("-?\\d*")); + } + + @Test + public void testRandomNumberWithDecimalPlaces() { + List params = List.of("2"); + String result = function.execute(params, context); + assertNotNull(result); + assertTrue(result.matches("-?\\d*\\.\\d{2}"), "result does not match pattern: "+result); + } + + @Test + public void testRandomNumberWithinRange() { + List params = List.of("2", "10.5", "20.5"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= 10.5 && randomValue <= 20.5); + } + + @Test + public void testRandomNumberIncludesMin() { + List params = List.of("1", "10.5", "20.5"); + function = new AdvancedRandomNumberFunction() { + @Override + double getRandomValue(double minValue, BigDecimal range, double random) { + random = 0.0; + return super.getRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertEquals(result, "10.5"); + } + + @Test + public void testRandomNumberIncludesMax() { + List params = List.of("1", "10.5", "20.5"); + function = new AdvancedRandomNumberFunction() { + @Override + double getRandomValue(double minValue, BigDecimal range, double random) { + random = 1.0; + return super.getRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertEquals(result, "20.5"); + } + + @Test + public void testRandomNumberExcludeMin() { + List params = List.of("1", "10.5", "20.5", "true", "false"); + function = new AdvancedRandomNumberFunction() { + @Override + double getRandomValue(double minValue, BigDecimal range, double random) { + random = 0.0; + return super.getRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue > 10.5 && randomValue <= 20.5); + } + + @Test + public void testRandomNumberExcludeMax() { + List params = List.of("2", "10.5", "20.5", "false", "true"); + function = new AdvancedRandomNumberFunction() { + @Override + double getRandomValue(double minValue, BigDecimal range, double random) { + random = 1.0; + return super.getRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= 10.5 && randomValue < 20.5); + } + + @Test + public void testRandomInteger32EdgeCase() { + List params = List.of("0", "-2147483648", "2147483647", "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Integer.MAX_VALUE && randomValue < Integer.MAX_VALUE); + } + + @Test + public void testRandomInteger32MinEqualsMaxEdgeCase() { + List params = List.of("0", "3", "3", "false", "false"); + for (int i =0;i<100;i++) { + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertEquals(randomValue, 3); + } + } + + + + // randomDouble('0','3','3','true','true') + // randomDouble('0','3','3','true','true') + + @Test + public void testRandomDouble32MinEqualsMaxEdgeCase() { + List params = List.of("2", "3.0", "3.0", "false", "false"); + for (int i =0;i<100;i++) { + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertEquals(randomValue, 3); + } + } + + @Test + public void testRandomInteger64EdgeCase() { + List params = List.of("0", "-9223372036854775808", "9223372036854775807", "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >=-Long.MAX_VALUE && randomValue < Long.MAX_VALUE); + } + + @Test + public void testRandomNumberFloatEdgeCase() { + List params = List.of("0", "-3.4028235E38", "3.4028235E38", "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Float.MAX_VALUE && randomValue < Float.MAX_VALUE); + } + + @Test + public void testRandomNumberDoubleEdgeCase() { + List params = List.of("0", "-1.7976931348623157E308", "1.7976931348623157E308", "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Double.MAX_VALUE && randomValue < Double.MAX_VALUE); + } + + @Test + public void testInvalidDecimalPlaces() { + List params = List.of("-1"); // invalid decimalPlaces + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); + assertEquals(exception.getMessage(), "Invalid parameter definition. Decimal places must be a non-negative integer value."); + } + + @Test + public void testInvalidRange() { + List params = List.of("2", "20.5", "10.5"); // invalid range + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); + assertEquals(exception.getMessage(), "Invalid parameter definition. Min value must be less than max value."); + } + + @Test + public void testInvalidDecimalPlacesFormat() { + List params = List.of("xxx"); // invalid decimalPlaces + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); + assertEquals(exception.getMessage(), "Invalid parameter at index 1. xxx must be parsable to Integer."); + } + + @Test + public void testInvalidMinValueFormat() { + List params = List.of("1","xxx"); // invalid min value + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); + assertEquals(exception.getMessage(), "Invalid parameter at index 2. xxx must be parsable to Double."); + } + + @Test + public void testInvalidMaxValueFormat() { + List params = List.of("1", "1.1", "xxx"); // invalid max value + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(params, context)); + assertEquals(exception.getMessage(), + "Invalid parameter at index 3. xxx must be parsable to Double."); + } + + private T expectThrows(Class exceptionClass, Runnable runnable) { + try { + runnable.run(); + } catch (Throwable throwable) { + if (exceptionClass.isInstance(throwable)) { + return exceptionClass.cast(throwable); + } else { + throw new AssertionError("Unexpected exception type", throwable); + } + } + throw new AssertionError("Expected exception not thrown"); + } +} \ No newline at end of file diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java new file mode 100644 index 0000000000..dccbb4b0fc --- /dev/null +++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java @@ -0,0 +1,75 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.functions.core; + +import static org.testng.Assert.assertTrue; + +import java.util.List; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.InvalidFunctionUsageException; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class RandomPatternFunctionTest { + + private final RandomPatternFunction function = new RandomPatternFunction(); + private final TestContext context = new TestContext(); + + @Test(expectedExceptions = InvalidFunctionUsageException.class) + public void testExecuteWithNullParameterList() { + function.execute(null, context); + } + + @Test(expectedExceptions = InvalidFunctionUsageException.class) + public void testExecuteWithEmptyPattern() { + function.execute(List.of(""), context); + } + + @Test + public void testExecuteWithValidPattern() { + String pattern = "[a-zA-Z0-9]{10}"; + String result = function.execute(List.of(pattern), context); + assertTrue(result.matches(pattern), "Generated string does not match the pattern"); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testExecuteWithInvalidPattern() { + String pattern = "[0-3]([a-c]|[e-g]{1"; // Invalid regex pattern with "Character range is out of order" + function.execute(List.of(pattern), context); + } + + @DataProvider(name = "patternProvider") + public Object[][] patternProvider() { + return new Object[][]{ + {"testExecuteWithComplexPattern", "(foo|bar)[0-9]{2,4}"}, + {"testIpv6", "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"}, + {"testIpv4", "(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}"}, + {"testEmail", "[a-z]{5,15}\\.?[a-z]{5,15}\\@[a-z]{5,15}\\.[a-z]{2}"}, + {"testUri", "((http|https)://[a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+(/[a-zA-Z0-9-]+){1,6})|(file:///[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+){1,6})"} + }; + } + + @Test(dataProvider = "patternProvider") + public void testPatterns(String description, String pattern) { + for (int i = 0; i < 100; i++) { + String result = function.execute(List.of(pattern), context); + assertTrue(result.matches(pattern), "Generated string does not match the pattern: " + description); + } + } + + +} diff --git a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java index 3883453738..2d78984fde 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java @@ -16,21 +16,122 @@ package org.citrusframework.util; +import static org.citrusframework.util.StringUtils.quote; +import static org.citrusframework.util.StringUtils.trimTrailingComma; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; + import org.testng.Assert; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; public class StringUtilsTest { @Test public void appendSegmentToPath() { - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("s1","s2"), "s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("s1/","s2"), "s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("s1/","/s2"), "s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1","/s2"), "/s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2"), "/s1/s2"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2/"), "/s1/s2/"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath("/s1/",null), "/s1/"); - Assert.assertEquals(StringUtils.appendSegmentToUrlPath(null,"/s2/"), "/s2/"); + assertEquals(StringUtils.appendSegmentToUrlPath("s1","s2"), "s1/s2"); + assertEquals(StringUtils.appendSegmentToUrlPath("s1/","s2"), "s1/s2"); + assertEquals(StringUtils.appendSegmentToUrlPath("s1/","/s2"), "s1/s2"); + assertEquals(StringUtils.appendSegmentToUrlPath("/s1","/s2"), "/s1/s2"); + assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2"), "/s1/s2"); + assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2/"), "/s1/s2/"); + assertEquals(StringUtils.appendSegmentToUrlPath("/s1/",null), "/s1/"); + assertEquals(StringUtils.appendSegmentToUrlPath(null,"/s2/"), "/s2/"); Assert.assertNull(StringUtils.appendSegmentToUrlPath(null,null)); } + + @Test + public void testQuoteTrue() { + String input = "Hello, World!"; + String expected = "\"Hello, World!\""; + String result = quote(input, true); + + assertEquals(result, expected, "The text should be quoted."); + } + + @Test + public void testQuoteFalse() { + String input = "Hello, World!"; + String expected = "Hello, World!"; + String result = quote(input, false); + + assertEquals(result, expected, "The text should not be quoted."); + } + + @Test + public void testQuoteEmptyStringTrue() { + String input = ""; + String expected = "\"\""; + String result = quote(input, true); + + assertEquals(result, expected, "The empty text should be quoted."); + } + + @Test + public void testQuoteEmptyStringFalse() { + String input = ""; + String expected = ""; + String result = quote(input, false); + + assertEquals(result, expected, "The empty text should not be quoted."); + } + + @Test + public void testQuoteNullStringTrue() { + String input = null; + String expected = "\"null\""; + String result = quote(input, true); + + assertEquals(result, expected, "The null text should be treated as a string 'null'."); + } + + @Test + public void testQuoteNullStringFalse() { + String input = null; + String result = quote(input, false); + + assertNull(result); + } + + @DataProvider(name = "trimTrailingCommaDataProvider") + public Object[][] trimTrailingCommaDataProvider() { + return new Object[][] { + { new StringBuilder("Example text, "), "Example text" }, + { new StringBuilder("No trailing comma "), "No trailing comma" }, + { new StringBuilder("No trailing comma,\n\t\n "), "No trailing comma" }, + { new StringBuilder("Trailing comma,"), "Trailing comma" }, + { new StringBuilder("Multiple commas and spaces,,, "), "Multiple commas and spaces,," }, + { new StringBuilder("No trim needed"), "No trim needed" }, + { new StringBuilder(), "" } + }; + } + + @Test(dataProvider = "trimTrailingCommaDataProvider") + public void testTrimTrailingComma(StringBuilder input, String expected) { + trimTrailingComma(input); + Assert.assertEquals(input.toString(), expected); + } + + @Test + public void testTrimTrailingCommaOnlySpaces() { + StringBuilder builder = new StringBuilder(" "); + trimTrailingComma(builder); + Assert.assertEquals(builder.toString(), ""); + + builder = new StringBuilder(","); + trimTrailingComma(builder); + Assert.assertEquals(builder.toString(), ""); + + builder = new StringBuilder(", , "); + trimTrailingComma(builder); + Assert.assertEquals(builder.toString(), ", "); + } + + @Test + public void testTrimTrailingCommaWithNull() { + StringBuilder builder = new StringBuilder(); + trimTrailingComma(builder); + Assert.assertEquals(builder.toString(), ""); + } + } diff --git a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java index 12a4b9f6cd..f615335225 100644 --- a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java +++ b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java @@ -16,6 +16,15 @@ package org.citrusframework.http.message; +import static org.citrusframework.http.message.HttpMessageHeaders.HTTP_QUERY_PARAMS; +import static org.citrusframework.util.StringUtils.hasText; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.commons.lang3.tuple.Pair; import org.citrusframework.message.Message; import org.citrusframework.message.MessageHeaders; @@ -39,8 +48,8 @@ private HttpMessageUtils() { */ public static void copy(Message from, HttpMessage to) { HttpMessage source; - if (from instanceof HttpMessage) { - source = (HttpMessage) from; + if (from instanceof HttpMessage httpMessage) { + source = httpMessage; } else { source = new HttpMessage(from); } @@ -66,4 +75,30 @@ public static void copy(HttpMessage from, HttpMessage to) { from.getHeaderData().forEach(to::addHeaderData); from.getCookies().forEach(to::cookie); } + + /** + * Extracts query parameters from the citrus HTTP message header and returns them as a map. + * + * @param httpMessage the HTTP message containing the query parameters in the header + * @return a map of query parameter names and their corresponding values + * @throws IllegalArgumentException if the query parameters are not formatted correctly + */ + public static Map> getQueryParameterMap(HttpMessage httpMessage) { + String queryParams = (String) httpMessage.getHeader(HTTP_QUERY_PARAMS); + if (hasText(queryParams)) { + return Arrays.stream(queryParams.split(",")) + .map(queryParameterKeyValue -> { + String[] keyAndValue = queryParameterKeyValue.split("=", 2); + if (keyAndValue.length == 0) { + throw new IllegalArgumentException("Query parameter must have a key."); + } + String key = keyAndValue[0]; + String value = keyAndValue.length > 1 ? keyAndValue[1] : ""; + return Pair.of(key, value); + }) + .collect(Collectors.groupingBy( + Pair::getLeft, Collectors.mapping(Pair::getRight, Collectors.toList()))); + } + return Collections.emptyMap(); + } } diff --git a/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java b/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java index d8f9099a11..896634fbfb 100644 --- a/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java +++ b/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java @@ -16,15 +16,24 @@ package org.citrusframework.http.message; +import static org.citrusframework.http.message.HttpMessageHeaders.HTTP_COOKIE_PREFIX; +import static org.citrusframework.http.message.HttpMessageUtils.getQueryParameterMap; +import static org.citrusframework.message.MessageHeaders.ID; +import static org.citrusframework.message.MessageHeaders.MESSAGE_TYPE; +import static org.citrusframework.message.MessageHeaders.TIMESTAMP; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + import jakarta.servlet.http.Cookie; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; - import org.citrusframework.message.DefaultMessage; import org.citrusframework.message.Message; -import org.citrusframework.message.MessageHeaders; import org.testng.Assert; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -47,16 +56,16 @@ public void testCopy() { HttpMessageUtils.copy(from, to); - Assert.assertNotEquals(from.getId(), to.getId()); - Assert.assertEquals(to.getName(), "FooMessage"); - Assert.assertEquals(to.getPayload(String.class), "fooMessage"); - Assert.assertEquals(to.getHeaders().size(), 4L); - Assert.assertNotNull(to.getHeader(MessageHeaders.ID)); - Assert.assertNotNull(to.getHeader(MessageHeaders.MESSAGE_TYPE)); - Assert.assertNotNull(to.getHeader(MessageHeaders.TIMESTAMP)); - Assert.assertEquals(to.getHeader("X-Foo"), "foo"); - Assert.assertEquals(to.getHeaderData().size(), 1L); - Assert.assertEquals(to.getHeaderData().get(0), "HeaderData"); + assertNotEquals(from.getId(), to.getId()); + assertEquals(to.getName(), "FooMessage"); + assertEquals(to.getPayload(String.class), "fooMessage"); + assertEquals(to.getHeaders().size(), 4L); + assertNotNull(to.getHeader(ID)); + assertNotNull(to.getHeader(MESSAGE_TYPE)); + assertNotNull(to.getHeader(TIMESTAMP)); + assertEquals(to.getHeader("X-Foo"), "foo"); + assertEquals(to.getHeaderData().size(), 1L); + assertEquals(to.getHeaderData().get(0), "HeaderData"); } @Test @@ -77,20 +86,20 @@ public void testCopyPreventExistingOverwritePayload() { HttpMessageUtils.copy(from, to); - Assert.assertNotEquals(from.getId(), to.getId()); - Assert.assertEquals(to.getName(), "FooMessage"); - Assert.assertEquals(to.getPayload(String.class), "fooMessage"); - Assert.assertEquals(to.getHeaders().size(), 7L); - Assert.assertNotNull(to.getHeader(MessageHeaders.ID)); - Assert.assertNotNull(to.getHeader(MessageHeaders.MESSAGE_TYPE)); - Assert.assertNotNull(to.getHeader(MessageHeaders.TIMESTAMP)); - Assert.assertEquals(to.getHeader("X-Foo"), "foo"); - Assert.assertEquals(to.getHeader("X-Existing"), "existing"); - Assert.assertEquals(to.getHeader(HttpMessageHeaders.HTTP_COOKIE_PREFIX + "Foo"), "Foo=fooCookie"); - Assert.assertEquals(to.getHeader(HttpMessageHeaders.HTTP_COOKIE_PREFIX + "Existing"), "Existing=existingCookie"); - Assert.assertEquals(to.getHeaderData().size(), 2L); - Assert.assertEquals(to.getHeaderData().get(0), "ExistingHeaderData"); - Assert.assertEquals(to.getHeaderData().get(1), "HeaderData"); + assertNotEquals(from.getId(), to.getId()); + assertEquals(to.getName(), "FooMessage"); + assertEquals(to.getPayload(String.class), "fooMessage"); + assertEquals(to.getHeaders().size(), 7L); + assertNotNull(to.getHeader(ID)); + assertNotNull(to.getHeader(MESSAGE_TYPE)); + assertNotNull(to.getHeader(TIMESTAMP)); + assertEquals(to.getHeader("X-Foo"), "foo"); + assertEquals(to.getHeader("X-Existing"), "existing"); + assertEquals(to.getHeader(HTTP_COOKIE_PREFIX + "Foo"), "Foo=fooCookie"); + assertEquals(to.getHeader(HTTP_COOKIE_PREFIX + "Existing"), "Existing=existingCookie"); + assertEquals(to.getHeaderData().size(), 2L); + assertEquals(to.getHeaderData().get(0), "ExistingHeaderData"); + assertEquals(to.getHeaderData().get(1), "HeaderData"); } @Test @@ -105,19 +114,19 @@ public void testConvertAndCopy() { HttpMessageUtils.copy(from, to); - Assert.assertNotEquals(from.getId(), to.getId()); - Assert.assertEquals(to.getName(), "FooMessage"); - Assert.assertEquals(to.getPayload(String.class), "fooMessage"); - Assert.assertEquals(to.getHeader("X-Foo"), "foo"); - Assert.assertEquals(to.getHeaderData().size(), 1L); - Assert.assertEquals(to.getHeaderData().get(0), "HeaderData"); + assertNotEquals(from.getId(), to.getId()); + assertEquals(to.getName(), "FooMessage"); + assertEquals(to.getPayload(String.class), "fooMessage"); + assertEquals(to.getHeader("X-Foo"), "foo"); + assertEquals(to.getHeaderData().size(), 1L); + assertEquals(to.getHeaderData().get(0), "HeaderData"); } @Test(dataProvider = "queryParamStrings") public void testQueryParamsExtraction(String queryParamString, Map params) { HttpMessage message = new HttpMessage(); message.queryParams(queryParamString); - Assert.assertEquals(message.getQueryParams().size(), params.size()); + assertEquals(message.getQueryParams().size(), params.size()); params.forEach((key, value) -> Assert.assertTrue(message.getQueryParams().get(key).contains(value))); } @@ -137,4 +146,55 @@ public Object[][] queryParamStrings() { .collect(Collectors.toMap(keyValue -> keyValue[0], keyValue -> keyValue[1])) } }; } + + + @Test + public void testGetQueryParameterMapWithValues() { + HttpMessage httpMessage = new HttpMessage(); + httpMessage.queryParam("q1", "v1"); + httpMessage.queryParam("q1", "v2"); + httpMessage.queryParam("q2", "v3"); + httpMessage.queryParam("q2", "v4"); + httpMessage.queryParam("q3", "v5"); + + Map> queryParams = getQueryParameterMap(httpMessage); + + assertEquals(queryParams.size(), 3); + List q1Values = queryParams.get("q1"); + assertTrue(q1Values.contains("v1")); + assertTrue(q1Values.contains("v2")); + List q2Values = queryParams.get("q2"); + assertTrue(q2Values.contains("v3")); + assertTrue(q2Values.contains("v4")); + List q3Values = queryParams.get("q3"); + assertTrue(q3Values.contains("v5")); + } + + @Test + public void testGetQueryParameterMapWithNoValues() { + HttpMessage httpMessage = new HttpMessage(); + + Map> queryParams = getQueryParameterMap(httpMessage); + + assertTrue(queryParams.isEmpty()); + } + + @Test + public void testGetQueryParameterMapWithMissingValues() { + HttpMessage httpMessage = new HttpMessage(); + httpMessage.queryParam("q1", ""); + httpMessage.queryParam("q2", ""); + httpMessage.queryParam("q3", ""); + + Map> queryParams = getQueryParameterMap(httpMessage); + + assertEquals(queryParams.size(), 3); + List q1Values = queryParams.get("q1"); + assertTrue(q1Values.contains("")); + List q2Values = queryParams.get("q2"); + assertTrue(q2Values.contains("")); + List q3Values = queryParams.get("q3"); + assertTrue(q3Values.contains("")); + } + } From 4d57556a8abbee1d4ab70ce8ee23c3bca95fcbc9 Mon Sep 17 00:00:00 2001 From: Thorsten Schlathoelter Date: Mon, 8 Jul 2024 00:12:03 +0200 Subject: [PATCH 7/7] feat(#1175): added random generator framework Framework was added in favour of OpenApiTestDataGenerator implementation --- .../openapi/OpenApiTestDataGenerator.java | 600 +----------------- .../OpenApiTestValidationDataGenerator.java | 12 +- .../OpenApiClientRequestActionBuilder.java | 9 +- .../OpenApiServerResponseActionBuilder.java | 27 +- .../openapi/random/RandomArrayGenerator.java | 51 ++ .../random/RandomCompositeGenerator.java | 74 +++ .../openapi/random/RandomConfiguration.java | 63 ++ .../openapi/random/RandomContext.java | 120 ++++ .../{util => random}/RandomElement.java | 2 +- .../openapi/random/RandomEnumGenerator.java | 25 + .../openapi/random/RandomGenerator.java | 61 ++ .../random/RandomGeneratorBuilder.java | 62 ++ .../{util => random}/RandomModelBuilder.java | 56 +- .../{util => random}/RandomModelWriter.java | 4 +- .../openapi/random/RandomNumberGenerator.java | 155 +++++ .../openapi/random/RandomObjectGenerator.java | 58 ++ .../openapi/random/RandomStringGenerator.java | 39 ++ .../openapi/util/OpenApiUtils.java | 11 + .../openapi/OpenApiTestDataGeneratorTest.java | 122 +--- .../random/OasRandomConfigurationTest.java | 179 ++++++ .../random/RandomArrayGeneratorTest.java | 106 ++++ .../random/RandomCompositeGeneratorTest.java | 90 +++ .../openapi/random/RandomContextTest.java | 76 +++ .../{util => random}/RandomElementTest.java | 2 +- .../random/RandomEnumGeneratorTest.java | 77 +++ .../random/RandomGeneratorBuilderTest.java | 92 +++ .../openapi/random/RandomGeneratorTest.java | 152 +++++ .../RandomModelBuilderTest.java | 48 +- .../random/RandomNumberGeneratorTest.java | 217 +++++++ .../random/RandomObjectGeneratorTest.java | 134 ++++ .../random/RandomStringGeneratorTest.java | 70 ++ ...OpenApiRequestValidationProcessorTest.java | 4 +- .../OpenApiRequestValidatorTest.java | 9 +- ...penApiResponseValidationProcessorTest.java | 4 +- .../OpenApiResponseValidatorTest.java | 36 +- .../functions/DefaultFunctionLibrary.java | 2 +- .../core/AdvancedRandomNumberFunction.java | 201 ++++-- .../functions/core/RandomPatternFunction.java | 5 +- .../functions/core/RandomStringFunction.java | 23 +- .../org/citrusframework/util/StringUtils.java | 2 +- .../AdvancedRandomNumberFunctionTest.java | 416 ++++++++++++ .../core/RandomDoubleFunctionTest.java | 246 ------- .../core/RandomStringFunctionTest.java | 32 +- 43 files changed, 2669 insertions(+), 1105 deletions(-) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java rename connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/{util => random}/RandomElement.java (98%) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java rename connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/{util => random}/RandomModelBuilder.java (62%) rename connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/{util => random}/RandomModelWriter.java (97%) create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java create mode 100644 connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java rename connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/{util => random}/RandomElementTest.java (98%) create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java rename connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/{util => random}/RandomModelBuilderTest.java (74%) create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java create mode 100644 connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java create mode 100644 core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java delete mode 100644 core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java index 6b5c7b2110..88e621dc12 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java @@ -16,230 +16,44 @@ package org.citrusframework.openapi; -import static java.lang.Boolean.TRUE; -import static java.lang.String.format; -import static org.citrusframework.openapi.OpenApiConstants.TYPE_INTEGER; -import static org.citrusframework.util.StringUtils.hasText; -import static org.citrusframework.util.StringUtils.quote; -import static org.springframework.util.CollectionUtils.isEmpty; - import io.apicurio.datamodels.openapi.models.OasSchema; -import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Collectors; import org.citrusframework.CitrusSettings; import org.citrusframework.context.TestContext; -import org.citrusframework.openapi.model.OasModelHelper; -import org.citrusframework.openapi.util.OpenApiUtils; -import org.citrusframework.openapi.util.RandomModelBuilder; +import org.citrusframework.openapi.random.RandomContext; /** * Generates proper payloads expressions based on Open API specification rules. */ public abstract class OpenApiTestDataGenerator { - public static final BigDecimal THOUSAND = new BigDecimal(1000); - public static final BigDecimal HUNDRED = BigDecimal.valueOf(100); - public static final BigDecimal MINUS_THOUSAND = new BigDecimal(-1000); - private OpenApiTestDataGenerator() { // Static access only } - private static final Map SPECIAL_FORMATS = Map.of( - "email", "[a-z]{5,15}\\.?[a-z]{5,15}\\@[a-z]{5,15}\\.[a-z]{2}", - "uri", - "((http|https)://[a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+(/[a-zA-Z0-9-]+){1,6})|(file:///[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+){1,6})", - "hostname", - "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])", - "ipv4", - "(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)", - "ipv6", - "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"); - /** * Creates payload from schema for outbound message. */ public static String createOutboundPayload(OasSchema schema, OpenApiSpecification specification) { - return createOutboundPayload(schema, - OasModelHelper.getSchemaDefinitions(specification.getOpenApiDoc(null)), specification); - } - - /** - * Creates payload from schema for outbound message. - */ - public static String createOutboundPayload(OasSchema schema, Map definitions, - OpenApiSpecification specification) { - return createOutboundPayload(schema, definitions, specification, new HashSet<>()); - } - - /** - * Creates payload from schema for outbound message. - */ - private static String createOutboundPayload(OasSchema schema, - Map definitions, - OpenApiSpecification specification, Set visitedRefSchemas) { - RandomModelBuilder randomModelBuilder = new RandomModelBuilder(); - createOutboundPayloadAsMap(randomModelBuilder, schema, definitions, specification, - visitedRefSchemas); - return randomModelBuilder.toString(); - } - - private static void createOutboundPayloadAsMap(RandomModelBuilder randomModelBuilder, - OasSchema schema, - Map definitions, - OpenApiSpecification specification, Set visitedRefSchemas) { - - if (hasText(schema.$ref) && visitedRefSchemas.contains(schema)) { - // Avoid recursion - return; - } - - if (OasModelHelper.isReferenceType(schema)) { - OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - createOutboundPayloadAsMap(randomModelBuilder, resolved, definitions, specification, - visitedRefSchemas); - return; - } - - if (OasModelHelper.isCompositeSchema(schema)) { - createComposedSchema(randomModelBuilder, schema, true, specification, - visitedRefSchemas); - return; - } - - switch (schema.type) { - case OpenApiConstants.TYPE_OBJECT -> - createRandomObjectSchemeMap(randomModelBuilder, schema, specification, - visitedRefSchemas); - case OpenApiConstants.TYPE_ARRAY -> - createRandomArrayValueMap(randomModelBuilder, schema, specification, - visitedRefSchemas); - case OpenApiConstants.TYPE_STRING, TYPE_INTEGER, OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_BOOLEAN -> - createRandomValueExpressionMap(randomModelBuilder, schema, true); - default -> randomModelBuilder.appendSimple("\"\""); - } + RandomContext randomContext = new RandomContext(specification, true); + randomContext.generate(schema); + return randomContext.getRandomModelBuilder().write(); } /** * Use test variable with given name if present or create value from schema with random values */ - public static String createRandomValueExpression(String name, OasSchema schema, - Map definitions, - boolean quotes, OpenApiSpecification specification, TestContext context) { + public static String createRandomValueExpression(String name, OasSchema schema, OpenApiSpecification specification, + TestContext context) { + if (context.getVariables().containsKey(name)) { return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX; } - return createRandomValueExpression(schema, definitions, quotes, specification); - } + RandomContext randomContext = new RandomContext(specification, false); + randomContext.generate(schema); + return randomContext.getRandomModelBuilder().write(); - /** - * Create payload from schema with random values. - */ - public static String createRandomValueExpression(OasSchema schema, - Map definitions, boolean quotes, - OpenApiSpecification specification) { - if (OasModelHelper.isReferenceType(schema)) { - OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - return createRandomValueExpression(resolved, definitions, quotes, specification); - } - - StringBuilder payload = new StringBuilder(); - if (OasModelHelper.isObjectType(schema) || OasModelHelper.isArrayType(schema)) { - payload.append(createOutboundPayload(schema, definitions, specification)); - } else if (OpenApiConstants.TYPE_STRING.equals(schema.type)) { - if (quotes) { - payload.append("\""); - } - if (OpenApiConstants.FORMAT_DATE.equals(schema.format)) { - payload.append("citrus:currentDate('yyyy-MM-dd')"); - } else if (OpenApiConstants.FORMAT_DATE_TIME.equals(schema.format)) { - payload.append("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')"); - } else if (hasText(schema.pattern)) { - payload.append("citrus:randomValue(").append(schema.pattern).append(")"); - } else if (!isEmpty(schema.enum_)) { - payload.append("citrus:randomEnumValue(").append( - schema.enum_.stream().map(value -> "'" + value + "'") - .collect(Collectors.joining(","))).append(")"); - } else if (OpenApiConstants.FORMAT_UUID.equals(schema.format)) { - payload.append("citrus:randomUUID()"); - } else { - if (schema.format != null && SPECIAL_FORMATS.containsValue(schema.format)) { - payload.append("citrus:randomValue('") - .append(SPECIAL_FORMATS.get(schema.format)).append("')"); - } else { - int length = 10; - if (schema.maxLength != null && schema.maxLength.intValue() > 0) { - length = schema.maxLength.intValue(); - } else if (schema.minLength != null && schema.minLength.intValue() > 0) { - length = schema.minLength.intValue(); - } - - payload.append("citrus:randomString(").append(length).append(")"); - } - } - - if (quotes) { - payload.append("\""); - } - } else if (OpenApiUtils.isAnyNumberScheme(schema)) { - payload.append("citrus:randomNumber(8)"); - } else if (OpenApiConstants.TYPE_BOOLEAN.equals(schema.type)) { - payload.append("citrus:randomEnumValue('true', 'false')"); - } else if (quotes) { - payload.append("\"\""); - } - - return payload.toString(); - } - - public static T createRawRandomValueExpression(OasSchema schema, - Map definitions, boolean quotes, - OpenApiSpecification specification, TestContext context) { - if (OasModelHelper.isReferenceType(schema)) { - OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref)); - return createRawRandomValueExpression(resolved, definitions, quotes, specification, - context); - } - - StringBuilder payload = new StringBuilder(); - if (OpenApiConstants.TYPE_STRING.equals(schema.type) || OasModelHelper.isObjectType(schema) - || OasModelHelper.isArrayType(schema)) { - return (T) createRandomValueExpression(schema, definitions, quotes, specification); - } else if (OpenApiConstants.TYPE_NUMBER.equals(schema.type)) { - return (T) Double.valueOf( - context.replaceDynamicContentInString("citrus:randomNumber(8,2)")); - } else if ("integer".equals(schema.type)) { - return (T) Double.valueOf( - context.replaceDynamicContentInString("citrus:randomNumber(8)")); - } else if ("boolean".equals(schema.type)) { - return (T) Boolean.valueOf( - context.replaceDynamicContentInString("citrus:randomEnumValue('true', 'false')")); - } else if (quotes) { - payload.append("\"\""); - } - - return (T) payload.toString(); - } - - /** - * Checks if given field name is in list of required fields for this schema. - */ - private static boolean isRequired(OasSchema schema, String field) { - if (schema.required == null) { - return true; - } - - return schema.required.contains(field); } /** @@ -248,398 +62,20 @@ private static boolean isRequired(OasSchema schema, String field) { */ public static String createRandomValueExpression(String name, OasSchema schema, TestContext context) { + if (context.getVariables().containsKey(name)) { return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX; } - RandomModelBuilder randomModelBuilder = new RandomModelBuilder(); - createRandomValueExpressionMap(randomModelBuilder, schema, false); - return randomModelBuilder.toString(); - } - - public static String createRandomValueExpression(OasSchema schema, boolean quotes) { - RandomModelBuilder randomModelBuilder = new RandomModelBuilder(); - createRandomValueExpressionMap(randomModelBuilder, schema, quotes); - return randomModelBuilder.toString(); - } - - /** - * Create random value expression using functions according to schema type and format. - */ - private static void createRandomValueExpressionMap(RandomModelBuilder randomModelBuilder, - OasSchema schema, boolean quotes) { - - switch (schema.type) { - case OpenApiConstants.TYPE_STRING -> { - if (OpenApiConstants.FORMAT_DATE.equals(schema.format)) { - randomModelBuilder.appendSimple( - quote("citrus:currentDate('yyyy-MM-dd')", quotes)); - } else if (OpenApiConstants.FORMAT_DATE_TIME.equals(schema.format)) { - randomModelBuilder.appendSimple( - quote("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')", quotes)); - } else if (hasText(schema.pattern)) { - randomModelBuilder.appendSimple( - quote("citrus:randomValue('" + schema.pattern + "')", quotes)); - } else if (!isEmpty(schema.enum_)) { - randomModelBuilder.appendSimple( - quote("citrus:randomEnumValue(" + (java.lang.String.join(",", schema.enum_)) - + ")", quotes)); - } else if (OpenApiConstants.FORMAT_UUID.equals(schema.format)) { - randomModelBuilder.appendSimple(quote("citrus:randomUUID()", quotes)); - } else { - - if (schema.format != null && SPECIAL_FORMATS.containsKey(schema.format)) { - randomModelBuilder.appendSimple(quote( - "citrus:randomValue('" + SPECIAL_FORMATS.get(schema.format) + "')", - quotes)); - } else { - long minLength = - schema.minLength != null && schema.minLength.longValue() > 0 - ? schema.minLength.longValue() : 10L; - long maxLength = - schema.maxLength != null && schema.maxLength.longValue() > 0 - ? schema.maxLength.longValue() : 10L; - long length = ThreadLocalRandom.current() - .nextLong(minLength, maxLength + 1); - randomModelBuilder.appendSimple( - quote("citrus:randomString(%s)".formatted(length), quotes)); - } - } - } - case OpenApiConstants.TYPE_NUMBER, TYPE_INTEGER -> - // No quotes for numbers - randomModelBuilder.appendSimple(createRandomNumber(schema)); - case OpenApiConstants.TYPE_BOOLEAN -> - // No quotes for boolean - randomModelBuilder.appendSimple("citrus:randomEnumValue('true', 'false')"); - default -> randomModelBuilder.appendSimple(""); - } - } - - private static String createRandomNumber(OasSchema schema) { - Number multipleOf = schema.multipleOf; - - boolean exclusiveMaximum = TRUE.equals(schema.exclusiveMaximum); - boolean exclusiveMinimum = TRUE.equals(schema.exclusiveMinimum); - - BigDecimal[] bounds = determineBounds(schema); - - BigDecimal minimum = bounds[0]; - BigDecimal maximum = bounds[1]; - - if (multipleOf != null) { - minimum = exclusiveMinimum ? incrementToExclude(minimum) : minimum; - maximum = exclusiveMaximum ? decrementToExclude(maximum) : maximum; - return createMultipleOf(minimum, maximum, new BigDecimal(multipleOf.toString())); - } - - return format( - "citrus:randomNumberGenerator('%d', '%s', '%s', '%s', '%s')", - determineDecimalPlaces(schema, minimum, maximum), - minimum, - maximum, - exclusiveMinimum, - exclusiveMaximum - ); - } - - /** - * Determines the number of decimal places to use based on the given schema and minimum/maximum values. - * For integer types, it returns 0. For other types, it returns the maximum number of decimal places - * found between the minimum and maximum values, with a minimum of 2 decimal places. - */ - private static int determineDecimalPlaces(OasSchema schema, BigDecimal minimum, - BigDecimal maximum) { - if (TYPE_INTEGER.equals(schema.type)) { - return 0; - } else { - return - Math.max(2, Math.max(findLeastSignificantDecimalPlace(minimum), - findLeastSignificantDecimalPlace(maximum))); - } - } - - /** - * Determine some reasonable bounds for a random number - */ - private static BigDecimal[] determineBounds(OasSchema schema) { - Number maximum = schema.maximum; - Number minimum = schema.minimum; - Number multipleOf = schema.multipleOf; - - BigDecimal bdMinimum; - BigDecimal bdMaximum; - if (minimum == null && maximum == null) { - bdMinimum = MINUS_THOUSAND; - bdMaximum = THOUSAND; - } else if (minimum == null) { - // Determine min relative to max - bdMaximum = new BigDecimal(maximum.toString()); - - if (multipleOf != null) { - bdMinimum = bdMaximum.subtract(new BigDecimal(multipleOf.toString()).abs().multiply( - HUNDRED)); - } else { - bdMinimum = bdMaximum.subtract(bdMaximum.multiply(BigDecimal.valueOf(2)).max( - THOUSAND)); - } - } else if (maximum == null) { - // Determine max relative to min - bdMinimum = new BigDecimal(minimum.toString()); - if (multipleOf != null) { - bdMaximum = bdMinimum.add(new BigDecimal(multipleOf.toString()).abs().multiply( - HUNDRED)); - } else { - bdMaximum = bdMinimum.add(bdMinimum.multiply(BigDecimal.valueOf(2)).max(THOUSAND)); - } - } else { - bdMaximum = new BigDecimal(maximum.toString()); - bdMinimum = new BigDecimal(minimum.toString()); - } - - return new BigDecimal[]{bdMinimum, bdMaximum}; - } - - /** - * Create a random schema value - * - * @param schema the type to create - * @param visitedRefSchemas the schemas already created during descent, used to avoid recursion - */ - private static void createRandomValue(RandomModelBuilder randomModelBuilder, OasSchema schema, - boolean quotes, - OpenApiSpecification specification, Set visitedRefSchemas) { - if (hasText(schema.$ref) && visitedRefSchemas.contains(schema)) { - // Avoid recursion - return; - } - - if (OasModelHelper.isReferenceType(schema)) { - OasSchema resolved = OasModelHelper.getSchemaDefinitions( - specification.getOpenApiDoc(null)) - .get(OasModelHelper.getReferenceName(schema.$ref)); - createRandomValue(randomModelBuilder, resolved, quotes, specification, - visitedRefSchemas); - return; - } - - if (OasModelHelper.isCompositeSchema(schema)) { - createComposedSchema(randomModelBuilder, schema, quotes, specification, - visitedRefSchemas); - return; - } - - switch (schema.type) { - case OpenApiConstants.TYPE_OBJECT -> - createRandomObjectSchemeMap(randomModelBuilder, schema, specification, - visitedRefSchemas); - case OpenApiConstants.TYPE_ARRAY -> - createRandomArrayValueMap(randomModelBuilder, schema, specification, - visitedRefSchemas); - case OpenApiConstants.TYPE_STRING, TYPE_INTEGER, OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_BOOLEAN -> - createRandomValueExpressionMap(randomModelBuilder, schema, quotes); - default -> { - if (quotes) { - randomModelBuilder.appendSimple("\"\""); - } else { - randomModelBuilder.appendSimple(""); - } - } - } - } - - private static void createRandomObjectSchemeMap(RandomModelBuilder randomModelBuilder, - OasSchema objectSchema, - OpenApiSpecification specification, Set visitedRefSchemas) { - - randomModelBuilder.object(() -> { - if (objectSchema.properties != null) { - for (Map.Entry entry : objectSchema.properties.entrySet()) { - if (specification.isGenerateOptionalFields() || isRequired(objectSchema, - entry.getKey())) { - randomModelBuilder.property(entry.getKey(), () -> - createRandomValue(randomModelBuilder, entry.getValue(), true, - specification, - visitedRefSchemas)); - } - } - } - }); - } - - private static void createComposedSchema(RandomModelBuilder randomModelBuilder, - OasSchema schema, boolean quotes, - OpenApiSpecification specification, Set visitedRefSchemas) { - - if (!isEmpty(schema.allOf)) { - createAllOff(randomModelBuilder, schema, quotes, specification, visitedRefSchemas); - } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.anyOf)) { - createAnyOf(randomModelBuilder, oas30Schema, quotes, specification, visitedRefSchemas); - } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.oneOf)) { - createOneOf(randomModelBuilder, oas30Schema.oneOf, quotes, specification, - visitedRefSchemas); - } - } - - private static void createOneOf(RandomModelBuilder randomModelBuilder, List schemas, - boolean quotes, - OpenApiSpecification specification, Set visitedRefSchemas) { - int schemaIndex = ThreadLocalRandom.current().nextInt(schemas.size()); - randomModelBuilder.object(() -> - createRandomValue(randomModelBuilder, schemas.get(schemaIndex), quotes, specification, - visitedRefSchemas)); + RandomContext randomContext = new RandomContext(); + randomContext.generate(schema); + return randomContext.getRandomModelBuilder().write(); } - private static void createAnyOf(RandomModelBuilder randomModelBuilder, Oas30Schema schema, - boolean quotes, - OpenApiSpecification specification, Set visitedRefSchemas) { - - randomModelBuilder.object(() -> { - boolean anyAdded = false; - for (OasSchema oneSchema : schema.anyOf) { - if (ThreadLocalRandom.current().nextBoolean()) { - createRandomValue(randomModelBuilder, oneSchema, quotes, specification, - visitedRefSchemas); - anyAdded = true; - } - } - - // Add at least one - if (!anyAdded) { - createOneOf(randomModelBuilder, schema.anyOf, quotes, specification, - visitedRefSchemas); - } - }); + public static String createRandomValueExpression(OasSchema schema) { + RandomContext randomContext = new RandomContext(); + randomContext.generate(schema); + return randomContext.getRandomModelBuilder().write(); } - private static Map createAllOff(RandomModelBuilder randomModelBuilder, - OasSchema schema, boolean quotes, - OpenApiSpecification specification, Set visitedRefSchemas) { - Map allOf = new HashMap<>(); - - randomModelBuilder.object(() -> { - for (OasSchema oneSchema : schema.allOf) { - createRandomValue(randomModelBuilder, oneSchema, quotes, specification, - visitedRefSchemas); - } - }); - - return allOf; - } - - private static String createMultipleOf( - BigDecimal minimum, - BigDecimal maximum, - BigDecimal multipleOf - ) { - - BigDecimal lowestMultiple = lowestMultipleOf(minimum, multipleOf); - BigDecimal largestMultiple = largestMultipleOf(maximum, multipleOf); - - // Check if there are no valid multiples in the range - if (lowestMultiple.compareTo(largestMultiple) > 0) { - return null; - } - - BigDecimal range = largestMultiple.subtract(lowestMultiple) - .divide(multipleOf, RoundingMode.DOWN); - - // Don't go for incredible large numbers - if (range.compareTo(BigDecimal.valueOf(11)) > 0) { - range = BigDecimal.valueOf(10); - } - - long factor = 0; - if (range.compareTo(BigDecimal.ZERO) != 0) { - factor = ThreadLocalRandom.current().nextLong(1, range.longValue() + 1); - } - BigDecimal randomMultiple = lowestMultiple.add( - multipleOf.multiply(BigDecimal.valueOf(factor))); - randomMultiple = randomMultiple.setScale(findLeastSignificantDecimalPlace(multipleOf), - RoundingMode.HALF_UP); - - return randomMultiple.toString(); - } - - /** - * Create a random array value. - * - * @param schema the type to create - * @param visitedRefSchemas the schemas already created during descent, used to avoid recursion - */ - @SuppressWarnings("rawtypes") - private static void createRandomArrayValueMap(RandomModelBuilder randomModelBuilder, - OasSchema schema, - OpenApiSpecification specification, Set visitedRefSchemas) { - Object items = schema.items; - - if (items instanceof OasSchema itemsSchema) { - createRandomArrayValueWithSchemaItem(randomModelBuilder, schema, itemsSchema, - specification, - visitedRefSchemas); - } else { - throw new UnsupportedOperationException( - "Random array creation for an array with items having different schema is currently not supported!"); - } - } - - private static void createRandomArrayValueWithSchemaItem(RandomModelBuilder randomModelBuilder, - OasSchema schema, - OasSchema itemsSchema, OpenApiSpecification specification, - Set visitedRefSchemas) { - Number minItems = schema.minItems; - minItems = minItems != null ? minItems : 1; - Number maxItems = schema.maxItems; - maxItems = maxItems != null ? maxItems : 9; - - int nItems = ThreadLocalRandom.current() - .nextInt(minItems.intValue(), maxItems.intValue() + 1); - - randomModelBuilder.array(() -> { - for (int i = 0; i < nItems; i++) { - createRandomValue(randomModelBuilder, itemsSchema, true, specification, - visitedRefSchemas); - } - }); - } - - static BigDecimal largestMultipleOf(BigDecimal highest, BigDecimal multipleOf) { - RoundingMode roundingMode = - highest.compareTo(BigDecimal.ZERO) < 0 ? RoundingMode.UP : RoundingMode.DOWN; - BigDecimal factor = highest.divide(multipleOf, 0, roundingMode); - return multipleOf.multiply(factor); - } - - static BigDecimal lowestMultipleOf(BigDecimal lowest, BigDecimal multipleOf) { - RoundingMode roundingMode = - lowest.compareTo(BigDecimal.ZERO) < 0 ? RoundingMode.DOWN : RoundingMode.UP; - BigDecimal factor = lowest.divide(multipleOf, 0, roundingMode); - return multipleOf.multiply(factor); - } - - static BigDecimal incrementToExclude(BigDecimal val) { - return val.add(determineIncrement(val)) - .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN); - } - - static BigDecimal decrementToExclude(BigDecimal val) { - return val.subtract(determineIncrement(val)) - .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN); - } - - static BigDecimal determineIncrement(BigDecimal number) { - return BigDecimal.valueOf(1.0d / (Math.pow(10d, findLeastSignificantDecimalPlace(number)))); - } - - static int findLeastSignificantDecimalPlace(BigDecimal number) { - number = number.stripTrailingZeros(); - - String[] parts = number.toPlainString().split("\\."); - - if (parts.length == 1) { - return 0; - } - - return parts[1].length(); - } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java index 3f9e123679..95033bb26c 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java @@ -175,7 +175,7 @@ private static String createValidationExpression(OasSchema schema) { } switch (schema.type) { - case "string" : + case OpenApiConstants.TYPE_STRING : if (schema.format != null && schema.format.equals("date")) { return "@matchesDatePattern('yyyy-MM-dd')@"; } else if (schema.format != null && schema.format.equals("date-time")) { @@ -190,7 +190,7 @@ private static String createValidationExpression(OasSchema schema) { } case OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_INTEGER: return "@isNumber()@"; - case "boolean" : + case OpenApiConstants.TYPE_BOOLEAN : return "@matches(true|false)@"; default: return "@ignore@"; @@ -219,7 +219,7 @@ public static String createValidationRegex(@Nullable OasSchema schema) { } switch (schema.type) { - case "string" : + case OpenApiConstants.TYPE_STRING: if (schema.format != null && schema.format.equals("date")) { return "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])"; } else if (schema.format != null && schema.format.equals("date-time")) { @@ -227,7 +227,7 @@ public static String createValidationRegex(@Nullable OasSchema schema) { } else if (hasText(schema.pattern)) { return schema.pattern; } else if (!isEmpty(schema.enum_)) { - return "(" + (String.join("|", schema.enum_)) + ")"; + return "(" + String.join("|", schema.enum_) + ")"; } else if (schema.format != null && schema.format.equals("uuid")) { return "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; } else { @@ -235,9 +235,9 @@ public static String createValidationRegex(@Nullable OasSchema schema) { } case OpenApiConstants.TYPE_NUMBER: return "[0-9]+\\.?[0-9]*"; - case "integer" : + case OpenApiConstants.TYPE_INTEGER: return "[0-9]+"; - case "boolean" : + case OpenApiConstants.TYPE_BOOLEAN: return "(true|false)"; default: return ""; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java index 1c86454b0b..cf244826c9 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java @@ -133,7 +133,7 @@ private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter if (context.getVariables().containsKey(parameter.getName())) { parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + CitrusSettings.VARIABLE_SUFFIX; } else { - parameterValue = OpenApiTestDataGenerator.createRandomValueExpression((OasSchema) parameter.schema, false); + parameterValue = OpenApiTestDataGenerator.createRandomValueExpression((OasSchema) parameter.schema); } randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}") .matcher(randomizedPath) @@ -152,8 +152,7 @@ private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter private void setSpecifiedBody(TestContext context, OasOperation operation) { Optional body = OasModelHelper.getRequestBodySchema( openApiSpec.getOpenApiDoc(context), operation); - body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, - OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), openApiSpec))); + body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema, openApiSpec))); } private void setSpecifiedQueryParameters(TestContext context, OasOperation operation) { @@ -180,9 +179,7 @@ private void setSpecifiedHeaders(TestContext context, OasOperation operation) { .forEach(param -> { if(httpMessage.getHeader(param.getName()) == null && !configuredHeaders.contains(param.getName())) { httpMessage.setHeader(param.getName(), - OpenApiTestDataGenerator.createRandomValueExpression(param.getName(), (OasSchema) param.schema, - OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc( - context)), false, openApiSpec, context)); + OpenApiTestDataGenerator.createRandomValueExpression(param.getName(), (OasSchema) param.schema, openApiSpec, context)); } }); } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java index 9384943f32..17611ae4ae 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java @@ -24,7 +24,6 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE; -import io.apicurio.datamodels.openapi.models.OasDocument; import io.apicurio.datamodels.openapi.models.OasOperation; import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; @@ -161,26 +160,24 @@ public Message build(TestContext context, String messageType) { } private void fillRandomData(OperationPathAdapter operationPathAdapter, TestContext context) { - OasDocument oasDocument = openApiSpec.getOpenApiDoc(context); if (operationPathAdapter.operation().responses != null) { - buildResponse(context, operationPathAdapter.operation(), oasDocument); + buildResponse(context, operationPathAdapter.operation()); } } - private void buildResponse(TestContext context, OasOperation operation, - OasDocument oasDocument) { + private void buildResponse(TestContext context, OasOperation operation) { Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( openApiSpec.getOpenApiDoc(context), operation, statusCode, null); if (responseForRandomGeneration.isPresent()) { - buildRandomHeaders(context, oasDocument, responseForRandomGeneration.get()); - buildRandomPayload(operation, oasDocument, responseForRandomGeneration.get()); + buildRandomHeaders(context, responseForRandomGeneration.get()); + buildRandomPayload(operation, responseForRandomGeneration.get()); } } - private void buildRandomHeaders(TestContext context, OasDocument oasDocument, OasResponse response) { + private void buildRandomHeaders(TestContext context, OasResponse response) { Set filteredHeaders = new HashSet<>(getMessage().getHeaders().keySet()); Predicate> filteredHeadersPredicate = entry -> !filteredHeaders.contains( entry.getKey()); @@ -192,7 +189,6 @@ private void buildRandomHeaders(TestContext context, OasDocument oasDocument, Oa .forEach(entry -> addHeaderBuilder(new DefaultHeaderBuilder( singletonMap(entry.getKey(), createRandomValueExpression(entry.getKey(), entry.getValue(), - OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context)))) ); @@ -210,8 +206,7 @@ private void buildRandomHeaders(TestContext context, OasDocument oasDocument, Oa + CitrusSettings.VARIABLE_SUFFIX))))); } - private void buildRandomPayload(OasOperation operation, OasDocument oasDocument, - OasResponse response) { + private void buildRandomPayload(OasOperation operation, OasResponse response) { Optional> schemaForMediaTypeOptional; if (statusCode.startsWith("2")) { @@ -228,7 +223,7 @@ private void buildRandomPayload(OasOperation operation, OasDocument oasDocument, OasAdapter schemaForMediaType = schemaForMediaTypeOptional.get(); if (getMessage().getPayload() == null || ( getMessage().getPayload() instanceof String string && string.isEmpty())) { - createRandomPayload(getMessage(), oasDocument, schemaForMediaType); + createRandomPayload(getMessage(), schemaForMediaType); } // If we have a schema and a media type and the content type has not yet been set, do it. @@ -239,7 +234,7 @@ private void buildRandomPayload(OasOperation operation, OasDocument oasDocument, } } - private void createRandomPayload(HttpMessage message, OasDocument oasDocument, OasAdapter schemaForMediaType) { + private void createRandomPayload(HttpMessage message, OasAdapter schemaForMediaType) { if (schemaForMediaType.node() == null) { // No schema means no payload, no type @@ -247,13 +242,11 @@ private void createRandomPayload(HttpMessage message, OasDocument oasDocument, O } else { if (TEXT_PLAIN_VALUE.equals(schemaForMediaType.adapted())) { // Schema but plain text - message.setPayload(createOutboundPayload(schemaForMediaType.node(), - OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)); + message.setPayload(createOutboundPayload(schemaForMediaType.node(), openApiSpec)); message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, TEXT_PLAIN_VALUE); } else if (APPLICATION_JSON_VALUE.equals(schemaForMediaType.adapted())) { // Json Schema - message.setPayload(createOutboundPayload(schemaForMediaType.node(), - OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)); + message.setPayload(createOutboundPayload(schemaForMediaType.node(), openApiSpec)); message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, APPLICATION_JSON_VALUE); } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java new file mode 100644 index 0000000000..afee6f3f68 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java @@ -0,0 +1,51 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.concurrent.ThreadLocalRandom; +import org.citrusframework.openapi.model.OasModelHelper; + +/** + * A generator for producing random arrays based on an OpenAPI schema. This class extends the + * {@link RandomGenerator} and provides a specific implementation for generating random arrays + * with constraints defined in the schema. + * + *

The generator supports arrays with items of a single schema type. If the array's items have + * different schemas, an {@link UnsupportedOperationException} will be thrown.

s + * + */ +public class RandomArrayGenerator extends RandomGenerator { + + @Override + public boolean handles(OasSchema other) { + return OasModelHelper.isArrayType(other); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + Object items = schema.items; + + if (items instanceof OasSchema itemsSchema) { + createRandomArrayValueWithSchemaItem(randomContext, schema, itemsSchema); + } else { + throw new UnsupportedOperationException( + "Random array creation for an array with items having different schema is currently not supported!"); + } + } + + private static void createRandomArrayValueWithSchemaItem(RandomContext randomContext, + OasSchema schema, + OasSchema itemsSchema) { + + Number minItems = schema.minItems != null ? schema.minItems : 1; + Number maxItems = schema.maxItems != null ? schema.maxItems : 10; + + int nItems = ThreadLocalRandom.current() + .nextInt(minItems.intValue(), maxItems.intValue() + 1); + + randomContext.getRandomModelBuilder().array(() -> { + for (int i = 0; i < nItems; i++) { + randomContext.generate(itemsSchema); + } + }); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java new file mode 100644 index 0000000000..6a7877ca39 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java @@ -0,0 +1,74 @@ +package org.citrusframework.openapi.random; + +import static org.springframework.util.CollectionUtils.isEmpty; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import org.citrusframework.openapi.model.OasModelHelper; + +/** + * A generator for producing random composite schemas based on an OpenAPI schema. This class extends + * the {@link RandomGenerator} and provides a specific implementation for generating composite schemas + * with constraints defined in the schema. + * + *

The generator supports composite schemas, which include `allOf`, `anyOf`, and `oneOf` constructs.

+ */ +public class RandomCompositeGenerator extends RandomGenerator { + + @Override + public boolean handles(OasSchema other) { + return OasModelHelper.isCompositeSchema(other); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + + if (!isEmpty(schema.allOf)) { + createAllOff(randomContext, schema); + } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.anyOf)) { + createAnyOf(randomContext, oas30Schema); + } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.oneOf)) { + createOneOf(randomContext, oas30Schema.oneOf); + } + } + + private static void createOneOf(RandomContext randomContext, List schemas) { + int schemaIndex = ThreadLocalRandom.current().nextInt(schemas.size()); + randomContext.getRandomModelBuilder().object(() -> + randomContext.generate(schemas.get(schemaIndex))); + } + + private static void createAnyOf(RandomContext randomContext, Oas30Schema schema) { + + randomContext.getRandomModelBuilder().object(() -> { + boolean anyAdded = false; + for (OasSchema oneSchema : schema.anyOf) { + if (ThreadLocalRandom.current().nextBoolean()) { + randomContext.generate(oneSchema); + anyAdded = true; + } + } + + // Add at least one + if (!anyAdded) { + createOneOf(randomContext, schema.anyOf); + } + }); + } + + private static Map createAllOff(RandomContext randomContext, OasSchema schema) { + Map allOf = new HashMap<>(); + + randomContext.getRandomModelBuilder().object(() -> { + for (OasSchema oneSchema : schema.allOf) { + randomContext.generate(oneSchema); + } + }); + + return allOf; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java new file mode 100644 index 0000000000..c986a16eb3 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java @@ -0,0 +1,63 @@ +package org.citrusframework.openapi.random; + +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_DATE; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_DATE_TIME; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_UUID; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_BOOLEAN; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_STRING; +import static org.citrusframework.openapi.random.RandomGenerator.ANY; +import static org.citrusframework.openapi.random.RandomGenerator.NULL_GENERATOR; +import static org.citrusframework.openapi.random.RandomGeneratorBuilder.builder; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Configuration class that initializes and manages a list of random generators + * for producing random data based on an OpenAPI schema. This class is a singleton + * and provides a static instance {@code RANDOM_CONFIGURATION} for global access. + */ +public class RandomConfiguration { + + private static final String EMAIL_PATTERN = "[a-z]{5,15}\\.?[a-z]{5,15}\\@[a-z]{5,15}\\.[a-z]{2}"; + private static final String URI_PATTERN = "((http|https)://[a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+(/[a-zA-Z0-9-]+){1,6})|(file:///[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+){1,6})"; + private static final String HOSTNAME_PATTERN = "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])"; + private static final String IPV4_PATTERN = "(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"; + private static final String IPV6_PATTERN = "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"; + + private final List randomGenerators; + + public static final RandomConfiguration RANDOM_CONFIGURATION = new RandomConfiguration(); + + private RandomConfiguration() { + List generators = new ArrayList<>(); + + // Note that the order of generators in the list is relevant, as the list is traversed from start to end, to find the first matching generator for a schema, and some generators match for less significant schemas. + generators.add(new RandomEnumGenerator()); + generators.add(builder(TYPE_STRING, FORMAT_DATE).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:currentDate('yyyy-MM-dd')"))); + generators.add(builder(TYPE_STRING, FORMAT_DATE_TIME).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')"))); + generators.add(builder(TYPE_STRING, FORMAT_UUID).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomUUID()"))); + generators.add(builder(TYPE_STRING, "email").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+EMAIL_PATTERN+"')"))); + generators.add(builder(TYPE_STRING, "uri").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+URI_PATTERN+"')"))); + generators.add(builder(TYPE_STRING, "hostname").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+HOSTNAME_PATTERN+"')"))); + generators.add(builder(TYPE_STRING, "ipv4").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+IPV4_PATTERN+"')"))); + generators.add(builder(TYPE_STRING,"ipv6").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+IPV6_PATTERN+"')"))); + generators.add(builder().withType(TYPE_STRING).withPattern(ANY).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+schema.pattern+"')"))); + generators.add(builder().withType(TYPE_BOOLEAN).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimple("citrus:randomEnumValue('true', 'false')"))); + generators.add(new RandomStringGenerator()); + generators.add(new RandomCompositeGenerator()); + generators.add(new RandomNumberGenerator()); + generators.add(new RandomObjectGenerator()); + generators.add(new RandomArrayGenerator()); + + randomGenerators = Collections.unmodifiableList(generators); + } + + public RandomGenerator getGenerator(OasSchema oasSchema) { + return randomGenerators.stream().filter(generator -> generator.handles(oasSchema)) + .findFirst() + .orElse(NULL_GENERATOR); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java new file mode 100644 index 0000000000..978d3b666b --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java @@ -0,0 +1,120 @@ +package org.citrusframework.openapi.random; + +import static org.citrusframework.openapi.random.RandomConfiguration.RANDOM_CONFIGURATION; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OasModelHelper; + +/** + * Context class for generating random values based on an OpenAPI specification. + * This class manages the state and configuration needed to generate random values + * for various schemas defined in the OpenAPI specification. + */ +public class RandomContext { + + private final OpenApiSpecification specification; + + private Map schemaDefinitions; + + private final RandomModelBuilder randomModelBuilder; + + /** + * Cache for storing variable during random value generation. + */ + private final Map contextVariables = new HashMap<>(); + + /** + * Constructs a default RandomContext backed by no specification. Note, that this context can not + * resolve referenced schemas, as no specification is available. + * + */ + public RandomContext() { + this.randomModelBuilder = new RandomModelBuilder(false); + this.specification = null; + } + + /** + * Constructs a new RandomContext with the specified OpenAPI specification and quote option. + * + * @param specification the OpenAPI specification + * @param quote whether to quote the generated random values + */ + public RandomContext(OpenApiSpecification specification, boolean quote) { + this.specification = specification; + this.randomModelBuilder = new RandomModelBuilder(quote); + } + + /** + * Generates random values based on the specified schema. + * + * @param schema the schema to generate random values for + */ + public void generate(OasSchema schema) { + doGenerate(resolveSchema(schema)); + } + + void doGenerate(OasSchema resolvedSchema) { + RANDOM_CONFIGURATION.getGenerator(resolvedSchema).generate(this, resolvedSchema); + } + + /** + * Resolves a schema, handling reference schemas by fetching the referenced schema definition. + * + * @param schema the schema to resolve + * @return the resolved schema + */ + OasSchema resolveSchema(OasSchema schema) { + if (OasModelHelper.isReferenceType(schema)) { + if (schemaDefinitions == null) { + schemaDefinitions = getSchemaDefinitions(); + } + schema = schemaDefinitions.get(OasModelHelper.getReferenceName(schema.$ref)); + } + return schema; + } + + /** + * Returns the RandomModelBuilder associated with this context. + * + * @return the RandomModelBuilder + */ + public RandomModelBuilder getRandomModelBuilder() { + return randomModelBuilder; + } + + /** + * Returns the OpenAPI specification associated with this context. + * + * @return the OpenAPI specification + */ + public OpenApiSpecification getSpecification() { + return specification; + } + + /** + * Returns the schema definitions from the specified OpenAPI document. + * + * @return a map of schema definitions + */ + Map getSchemaDefinitions() { + return specification != null ?OasModelHelper.getSchemaDefinitions(specification.getOpenApiDoc(null)) : Collections.emptyMap(); + } + + /** + * Retrieves a context variable by key, computing its value if necessary using the provided mapping function. + * + * @param the type of the context variable + * @param key the key of the context variable + * @param mappingFunction the function to compute the value if it is not present + * @return the context variable value + */ + public T get(String key, Function mappingFunction) { + //noinspection unchecked + return (T) contextVariables.computeIfAbsent(key, mappingFunction); + } +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomElement.java similarity index 98% rename from connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java rename to connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomElement.java index 4a31733459..4c515a4256 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomElement.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomElement.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.citrusframework.openapi.util; +package org.citrusframework.openapi.random; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java new file mode 100644 index 0000000000..67b46e8be7 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java @@ -0,0 +1,25 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import java.util.stream.Collectors; + +public class RandomEnumGenerator extends RandomGenerator { + + + @Override + public boolean handles(OasSchema other) { + return other.enum_ != null; + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + List anEnum = schema.enum_; + if (anEnum != null) { + String enumValues = schema.enum_.stream().map(value -> "'" + value + "'") + .collect(Collectors.joining(",")); + randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomEnumValue(%s)".formatted(enumValues)); + } + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java new file mode 100644 index 0000000000..9f74422e48 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java @@ -0,0 +1,61 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.Objects; + +/** + * Abstract base class for generators that produce random data based on an OpenAPI schema. + * Subclasses must implement the {@link #generate} method to provide specific random data generation logic. + * + *

The class provides methods for determining if a generator can handle a given schema, + * based on the schema type, format, pattern, and enum constraints. + */ +public abstract class RandomGenerator { + + public static final String ANY = "$ANY$"; + + private final OasSchema schema; + + protected RandomGenerator() { + this.schema = null; + } + + protected RandomGenerator(OasSchema schema) { + this.schema = schema; + } + + public boolean handles(OasSchema other) { + if (other == null || schema == null) { + return false; + } + + if (ANY.equals(schema.type) || Objects.equals(schema.type, other.type)) { + if (schema.format != null) { + return (ANY.equals(schema.format) && other.format != null)|| Objects.equals(schema.format, other.format); + } + + if (schema.pattern != null) { + return (ANY.equals(schema.pattern) && other.pattern != null) || Objects.equals(schema.pattern, other.pattern); + } + + if (schema.enum_ != null && other.enum_ != null) { + return true; + } + + return true; + } + + return false; + } + + abstract void generate(RandomContext randomContext, OasSchema schema); + + public static final RandomGenerator NULL_GENERATOR = new RandomGenerator() { + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + // Do nothing + } + }; + +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java new file mode 100644 index 0000000000..8fbeac3ef4 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java @@ -0,0 +1,62 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.Collections; +import java.util.function.BiConsumer; + +/** + * A simple builder for building {@link java.util.random.RandomGenerator}s. + */ +public class RandomGeneratorBuilder { + + private final OasSchema schema = new Oas30Schema(); + + private RandomGeneratorBuilder() { + } + + static RandomGeneratorBuilder builder() { + return new RandomGeneratorBuilder(); + } + + static RandomGeneratorBuilder builder(String type, String format) { + return new RandomGeneratorBuilder().with(type, format); + } + + RandomGeneratorBuilder with(String type, String format) { + schema.type = type; + schema.format = format; + return this; + } + + + RandomGeneratorBuilder withType(String type) { + schema.type = type; + return this; + } + + RandomGeneratorBuilder withFormat(String format) { + schema.format = format; + return this; + } + + RandomGeneratorBuilder withPattern(String pattern) { + schema.pattern = pattern; + return this; + } + + RandomGeneratorBuilder withEnum() { + schema.enum_ = Collections.emptyList(); + return this; + } + + RandomGenerator build(BiConsumer consumer) { + return new RandomGenerator(schema) { + @Override + void generate(RandomContext randomContext, OasSchema schema) { + consumer.accept(randomContext, schema); + } + }; + } + +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelBuilder.java similarity index 62% rename from connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java rename to connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelBuilder.java index 43346b388a..c2ff6bbfea 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelBuilder.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelBuilder.java @@ -14,22 +14,21 @@ * limitations under the License. */ -package org.citrusframework.openapi.util; +package org.citrusframework.openapi.random; import java.util.ArrayDeque; import java.util.Deque; -import org.citrusframework.openapi.util.RandomElement.RandomList; -import org.citrusframework.openapi.util.RandomElement.RandomObject; -import org.citrusframework.openapi.util.RandomElement.RandomValue; +import org.citrusframework.openapi.random.RandomElement.RandomList; +import org.citrusframework.openapi.random.RandomElement.RandomObject; +import org.citrusframework.openapi.random.RandomElement.RandomValue; /** - * RandomModelBuilder is a class for building random JSON models. It supports adding - * simple values, objects, properties, and arrays to the JSON structure. The final - * model can be converted to a JSON string using the `writeToJson` method. I + * RandomModelBuilder is a class for building random JSON models. It supports adding simple values, + * objects, properties, and arrays to the JSON structure. The final model can be converted to a JSON + * string using the `writeToJson` method. I *

- * The builder is able to build nested structures and can also handle native string, - * number, and boolean elements, represented as functions for later dynamic string - * conversion by Citrus. + * The builder is able to build nested structures and can also handle native string, number, and + * boolean elements, represented as functions for later dynamic string conversion by Citrus. *

* Example usage: *

@@ -48,22 +47,45 @@ public class RandomModelBuilder {
 
     final Deque deque = new ArrayDeque<>();
 
-    public RandomModelBuilder() {
+    private final boolean quote;
+
+    /**
+     * Creates a {@link RandomModelBuilder} in respective quoting mode.
+     * Quoting should be activated in case an object is created by the builder. In this case,
+     * all properties added by respective "quoted" methods, will be quoted.
+     *
+     * @param quote whether to run the builder in quoting mode or not.
+     */
+    public RandomModelBuilder(boolean quote) {
         deque.push(new RandomValue());
+        this.quote = quote;
     }
 
-    public String toString() {
+    public String write() {
         return RandomModelWriter.toString(this);
     }
 
-    public void appendSimple(String nativeValue) {
+    /**
+     * Append the simpleValue as is, no quoting
+     */
+    public void appendSimple(String simpleValue) {
         if (deque.isEmpty()) {
-            deque.push(new RandomValue(nativeValue));
+            deque.push(new RandomValue(simpleValue));
         } else {
-            deque.peek().push(nativeValue);
+            deque.peek().push(simpleValue);
         }
     }
 
+    /**
+     * If the builder is in quoting mode, the native value will be quoted, otherwise it will be
+     * added as ist.
+     *s
+     * @param simpleValue
+     */
+    public void appendSimpleQuoted(String simpleValue) {
+        appendSimple(quote(simpleValue));
+    }
+
     public void object(Runnable objectBuilder) {
         if (deque.isEmpty()) {
             throwIllegalState();
@@ -106,4 +128,8 @@ public void array(Runnable arrayBuilder) {
         deque.pop();
     }
 
+    public String quote(String text) {
+        return quote ? String.format("\"%s\"", text) : text;
+    }
+
 }
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelWriter.java
similarity index 97%
rename from connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java
rename to connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelWriter.java
index 11960d0973..2c37e621e5 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/RandomModelWriter.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelWriter.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.citrusframework.openapi.util;
+package org.citrusframework.openapi.random;
 
 import static org.citrusframework.util.StringUtils.trimTrailingComma;
 
@@ -22,7 +22,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import org.citrusframework.openapi.util.RandomElement.RandomValue;
+import org.citrusframework.openapi.random.RandomElement.RandomValue;
 
 /**
  * Utility class for converting a {@link RandomModelBuilder} to its string representation.
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java
new file mode 100644
index 0000000000..a9c0742259
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java
@@ -0,0 +1,155 @@
+package org.citrusframework.openapi.random;
+
+import static java.lang.Boolean.TRUE;
+import static java.lang.String.format;
+import static org.citrusframework.openapi.OpenApiConstants.TYPE_INTEGER;
+
+import io.apicurio.datamodels.openapi.models.OasSchema;
+import java.math.BigDecimal;
+import org.citrusframework.openapi.util.OpenApiUtils;
+
+/**
+ * A generator for producing random numbers based on an OpenAPI schema. This class extends the
+ * {@link RandomGenerator} and provides a specific implementation for generating random numbers with
+ * constraints defined in the schema.
+ *
+ * 

Supported constraints: + *

    + *
  • minimum: The minimum value for the generated number.
  • + *
  • maximum: The maximum value for the generated number.
  • + *
  • exclusiveMinimum: If true, the generated number will be strictly greater than the minimum.
  • + *
  • exclusiveMaximum: If true, the generated number will be strictly less than the maximum.
  • + *
  • multipleOf: The generated number will be a multiple of this value.
  • + *
+ * + *

The generator supports generating numbers for both integer and floating-point types, including + * int32, int64, double, and float. This support + * extends to the multipleOf constraint, ensuring that the generated numbers can be precise + * multiples of the specified value. + * + *

The generator determines the appropriate bounds and constraints based on the provided schema + * and generates a random number accordingly. + */ +public class RandomNumberGenerator extends RandomGenerator { + + public static final BigDecimal THOUSAND = new BigDecimal(1000); + public static final BigDecimal HUNDRED = java.math.BigDecimal.valueOf(100); + public static final BigDecimal MINUS_THOUSAND = new BigDecimal(-1000); + + @Override + public boolean handles(OasSchema other) { + return OpenApiUtils.isAnyNumberScheme(other); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + + boolean exclusiveMaximum = TRUE.equals(schema.exclusiveMaximum); + boolean exclusiveMinimum = TRUE.equals(schema.exclusiveMinimum); + + BigDecimal[] bounds = determineBounds(schema); + + BigDecimal minimum = bounds[0]; + BigDecimal maximum = bounds[1]; + + if (schema.multipleOf != null) { + randomContext.getRandomModelBuilder().appendSimple(format( + "citrus:randomNumberGenerator('%d', '%s', '%s', '%s', '%s', '%s')", + determineDecimalPlaces(schema, minimum, maximum), + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + schema.multipleOf + )); + } else { + randomContext.getRandomModelBuilder().appendSimple(format( + "citrus:randomNumberGenerator('%d', '%s', '%s', '%s', '%s')", + determineDecimalPlaces(schema, minimum, maximum), + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum + )); + } + } + + /** + * Determines the number of decimal places to use based on the given schema and + * minimum/maximum/multipleOf values. For integer types, it returns 0. For other types, it + * returns the maximum number of decimal places found between the minimum and maximum values, + * with a minimum of 2 decimal places. + */ + private int determineDecimalPlaces(OasSchema schema, BigDecimal minimum, + BigDecimal maximum) { + if (TYPE_INTEGER.equals(schema.type)) { + return 0; + } else { + Number multipleOf = schema.multipleOf; + if (multipleOf != null) { + return findLeastSignificantDecimalPlace(new BigDecimal(multipleOf.toString())); + } + + return Math.max(2, Math.max(findLeastSignificantDecimalPlace(minimum), + findLeastSignificantDecimalPlace(maximum))); + + } + } + + /** + * Determine some reasonable bounds for a random number + */ + private static BigDecimal[] determineBounds(OasSchema schema) { + Number maximum = schema.maximum; + Number minimum = schema.minimum; + Number multipleOf = schema.multipleOf; + + BigDecimal bdMinimum; + BigDecimal bdMaximum; + + if (minimum == null && maximum == null) { + bdMinimum = MINUS_THOUSAND; + bdMaximum = THOUSAND; + } else if (minimum == null) { + bdMaximum = new BigDecimal(maximum.toString()); + bdMinimum = calculateMinRelativeToMax(bdMaximum, multipleOf); + } else if (maximum == null) { + bdMinimum = new BigDecimal(minimum.toString()); + bdMaximum = calculateMaxRelativeToMin(bdMinimum, multipleOf); + } else { + bdMinimum = new BigDecimal(minimum.toString()); + bdMaximum = new BigDecimal(maximum.toString()); + } + + return new BigDecimal[]{bdMinimum, bdMaximum}; + } + + static BigDecimal calculateMinRelativeToMax(BigDecimal max, Number multipleOf) { + if (multipleOf != null) { + return max.subtract(new BigDecimal(multipleOf.toString()).abs().multiply(HUNDRED)); + } else { + return max.subtract(max.multiply(BigDecimal.valueOf(2)).max(THOUSAND)); + } + } + + static BigDecimal calculateMaxRelativeToMin(BigDecimal min, Number multipleOf) { + if (multipleOf != null) { + return min.add(new BigDecimal(multipleOf.toString()).abs().multiply(HUNDRED)); + } else { + return min.add(min.multiply(BigDecimal.valueOf(2)).max(THOUSAND)); + } + } + + int findLeastSignificantDecimalPlace(BigDecimal number) { + number = number.stripTrailingZeros(); + + String[] parts = number.toPlainString().split("\\."); + + if (parts.length == 1) { + return 0; + } + + return parts[1].length(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java new file mode 100644 index 0000000000..8b7ef2b9d4 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java @@ -0,0 +1,58 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import org.citrusframework.openapi.OpenApiConstants; +import org.citrusframework.openapi.util.OpenApiUtils; + +/** + * A generator for producing random objects based on an OpenAPI schema. This class extends + * the {@link RandomGenerator} and provides a specific implementation for generating objects + * with properties defined in the schema. + * + *

The generator supports object schemas and prevents recursion by keeping track of the + * schemas being processed.

+ */ +public class RandomObjectGenerator extends RandomGenerator { + + private static final String OBJECT_STACK = "OBJECT_STACK"; + + private static final OasSchema OBJECT_SCHEMA = new Oas30Schema(); + + static { + OBJECT_SCHEMA.type = OpenApiConstants.TYPE_OBJECT; + } + + public RandomObjectGenerator() { + super(OBJECT_SCHEMA); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + + Deque objectStack = randomContext.get(OBJECT_STACK, k -> new ArrayDeque<>()); + + if (objectStack.contains(schema)) { + // If we have already created this schema, we are very likely in a recursion and need to stop. + return; + } + + objectStack.push(schema); + randomContext.getRandomModelBuilder().object(() -> { + if (schema.properties != null) { + for (Map.Entry entry : schema.properties.entrySet()) { + if (randomContext.getSpecification().isGenerateOptionalFields() || OpenApiUtils.isRequired(schema, + entry.getKey())) { + randomContext.getRandomModelBuilder().property(entry.getKey(), () -> + randomContext.generate(entry.getValue())); + } + } + } + }); + objectStack.pop(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java new file mode 100644 index 0000000000..cc30c220eb --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java @@ -0,0 +1,39 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.citrusframework.openapi.OpenApiConstants; + +/** + * A generator for producing random strings based on an OpenAPI schema. + * This class extends the {@link RandomGenerator} and provides a specific implementation + * for generating random strings with constraints defined in the schema. + */ +public class RandomStringGenerator extends RandomGenerator { + + private static final OasSchema STRING_SCHEMA = new Oas30Schema(); + + static { + STRING_SCHEMA.type = OpenApiConstants.TYPE_STRING; + } + + public RandomStringGenerator() { + super(STRING_SCHEMA); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + int min = 1; + int max = 10; + + if (schema.minLength != null && schema.minLength.intValue() > 0) { + min = schema.minLength.intValue(); + } + + if (schema.maxLength != null && schema.maxLength.intValue() > 0) { + max = schema.maxLength.intValue(); + } + + randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomString(%s,MIXED,true,%s)".formatted(max, min)); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java index dd6e48deb7..ae4008111a 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java @@ -62,4 +62,15 @@ public static boolean isAnyNumberScheme(OasSchema schema) { ); } + /** + * Checks if given field name is in list of required fields for this schema. + */ + public static boolean isRequired(OasSchema schema, String field) { + if (schema.required == null) { + return true; + } + + return schema.required.contains(field); + } + } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java index 59227c5cd7..6ac0dd9d60 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java @@ -72,64 +72,13 @@ public static void beforeClass() { .getSchemaValidator(); } - @DataProvider(name = "findLeastSignificantDecimalPlace") - public static Object[][] findLeastSignificantDecimalPlace() { - return new Object[][]{ - {new BigDecimal("1234.5678"), 4}, - {new BigDecimal("123.567"), 3}, - {new BigDecimal("123.56"), 2}, - {new BigDecimal("123.5"), 1}, - {new BigDecimal("123.0"), 0}, - {new BigDecimal("123"), 0} - }; - } - - @Test(dataProvider = "findLeastSignificantDecimalPlace") - void findLeastSignificantDecimalPlace(BigDecimal number, int expectedSignificance) { - assertEquals(OpenApiTestDataGenerator.findLeastSignificantDecimalPlace(number), - expectedSignificance); - } - - @DataProvider(name = "incrementToExclude") - public static Object[][] incrementToExclude() { - return new Object[][]{ - {new BigDecimal("1234.678"), new BigDecimal("1234.679")}, - {new BigDecimal("1234.78"), new BigDecimal("1234.79")}, - {new BigDecimal("1234.8"), new BigDecimal("1234.9")}, - {new BigDecimal("1234.0"), new BigDecimal("1235")}, - {new BigDecimal("1234"), new BigDecimal("1235")}, - }; - } - - @Test(dataProvider = "incrementToExclude") - void incrementToExclude(BigDecimal value, BigDecimal expectedValue) { - assertEquals(OpenApiTestDataGenerator.incrementToExclude(value), expectedValue); - } - - @DataProvider(name = "decrementToExclude") - public static Object[][] decrementToExclude() { - return new Object[][]{ - {new BigDecimal("1234.678"), new BigDecimal("1234.677")}, - {new BigDecimal("1234.78"), new BigDecimal("1234.77")}, - {new BigDecimal("1234.8"), new BigDecimal("1234.7")}, - {new BigDecimal("1234.0"), new BigDecimal("1233")}, - {new BigDecimal("1234"), new BigDecimal("1233")}, - }; - } - - @Test(dataProvider = "decrementToExclude") - void decrementToExclude(BigDecimal value, BigDecimal expectedValue) { - assertEquals(OpenApiTestDataGenerator.decrementToExclude(value), expectedValue); - } - @Test void testUuidFormat() { Oas30Schema stringSchema = new Oas30Schema(); stringSchema.type = TYPE_STRING; stringSchema.format = FORMAT_UUID; - String uuidRandomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema, - false); + String uuidRandomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema); String finalUuidRandomValue = testContext.replaceDynamicContentInString(uuidRandomValue); Pattern uuidPattern = Pattern.compile( "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); @@ -300,8 +249,7 @@ void testPattern() { String exp = "[0-3]([a-c]|[e-g]{1,2})"; stringSchema.pattern = exp; - String randomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema, - false); + String randomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema); String finalRandomValue = testContext.replaceDynamicContentInString(randomValue); assertTrue(finalRandomValue.matches(exp), "Value '%s' does not match expression '%s'".formatted(finalRandomValue, exp)); @@ -315,18 +263,17 @@ public static Object[][] testPingApiSchemas() { //{"AnyOfType"}, //{"AllOfType"}, //{"PingRespType"}, - {"OneOfType"}, {"StringsType"}, {"DatesType"}, {"NumbersType"}, - {"MultipleOfType"}, {"PingReqType"}, {"Detail1"}, {"Detail2"}, {"BooleanType"}, {"EnumType"}, {"NestedType"}, + {"MultipleOfType"}, {"SimpleArrayType"}, {"ComplexArrayType"}, {"ArrayOfArraysType"}, @@ -354,13 +301,11 @@ void testPingApiSchemas(String schemaType) throws IOException { assertNotNull(randomValue); String finalJsonAsText = testContext.replaceDynamicContentInString(randomValue); - try { JsonNode valueNode = new ObjectMapper().readTree( testContext.replaceDynamicContentInString(finalJsonAsText)); ValidationReport validationReport = schemaValidator.validate(() -> valueNode, - swaggerValidationSchema, - "response.body"); + swaggerValidationSchema, null); String message = """ Json is invalid according to schema. @@ -430,7 +375,7 @@ void testArrayMaxItems() { arraySchema.items = stringSchema; - Pattern pattern = Pattern.compile("citrus:randomString\\(1[0-5]\\)"); + Pattern pattern = Pattern.compile("citrus:randomString\\(1[0-5],MIXED,true,10\\)"); for (int i = 0; i < 100; i++) { String randomArrayValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, openApiSpecification); @@ -447,61 +392,4 @@ void testArrayMaxItems() { } } - @Test - public void testLowestMultipleOf() { - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(10)), BigDecimal.valueOf(-1000)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(-10)), BigDecimal.valueOf(-1000)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(11)), BigDecimal.valueOf(-990)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(-11)), BigDecimal.valueOf(-990)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(11.1234)), BigDecimal.valueOf(-989.9826)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(-11.1234)), BigDecimal.valueOf(-989.9826)); - - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(10)), BigDecimal.valueOf(1000)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(-10)), BigDecimal.valueOf(1000)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(11)), BigDecimal.valueOf(1001)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(-11)), BigDecimal.valueOf(1001)); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(11.1234)), new BigDecimal("1001.1060")); - assertEquals(OpenApiTestDataGenerator.lowestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(-11.1234)), new BigDecimal("1001.1060")); - } - - @Test - public void testLargestMultipleOf() { - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(10)), BigDecimal.valueOf(-1000)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(-10)), BigDecimal.valueOf(-1000)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(11)), BigDecimal.valueOf(-1001)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(-11)), BigDecimal.valueOf(-1001)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(11.1234)), new BigDecimal("-1001.1060")); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(-1000), - BigDecimal.valueOf(-11.1234)), new BigDecimal("-1001.1060")); - - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(10)), BigDecimal.valueOf(1000)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(-10)), BigDecimal.valueOf(1000)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(11)), BigDecimal.valueOf(990)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(-11)), BigDecimal.valueOf(990)); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(11.1234)), new BigDecimal("989.9826")); - assertEquals(OpenApiTestDataGenerator.largestMultipleOf(BigDecimal.valueOf(1000), - BigDecimal.valueOf(-11.1234)), new BigDecimal("989.9826")); - } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java new file mode 100644 index 0000000000..43fd6d628b --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java @@ -0,0 +1,179 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.util.List; + +import static org.citrusframework.openapi.OpenApiConstants.*; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +public class OasRandomConfigurationTest { + + private RandomConfiguration randomConfiguration; + + @BeforeClass + public void setUp() { + randomConfiguration = RandomConfiguration.RANDOM_CONFIGURATION; + } + + @Test + public void testGetGeneratorForDateFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = FORMAT_DATE; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForDateTimeFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = FORMAT_DATE_TIME; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForUUIDFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = FORMAT_UUID; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForEmailFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "email"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForURIFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "uri"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForHostnameFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "hostname"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForIPv4Format() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "ipv4"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForIPv6Format() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "ipv6"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForBooleanType() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_BOOLEAN; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForStringType() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForNumberType() { + OasSchema schema = new Oas30Schema(); + schema.type = "number"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForObjectType() { + OasSchema schema = new Oas30Schema(); + schema.type = "object"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForArrayType() { + OasSchema schema = new Oas30Schema(); + schema.type = "array"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForEnum() { + OasSchema schema = new Oas30Schema(); + schema.enum_ = List.of("value1", "value2"); + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForNullSchema() { + OasSchema schema = new Oas30Schema(); + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertSame(generator, RandomGenerator.NULL_GENERATOR); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java new file mode 100644 index 0000000000..c361e0f1f6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java @@ -0,0 +1,106 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.citrusframework.openapi.OpenApiConstants; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomArrayGeneratorTest { + + private RandomArrayGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder builderSpy; + + @BeforeMethod + public void setUp() { + generator = new RandomArrayGenerator(); + mockContext = mock(); + + builderSpy = spy(new RandomModelBuilder(true)); + + when(mockContext.getRandomModelBuilder()).thenReturn(builderSpy); + } + + @Test + public void testGenerateArrayWithDefaultItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atLeastOnce()).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateArrayWithMinItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + schema.minItems = 5; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atLeast(5)).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateArrayWithMaxItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + schema.maxItems = 3; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atMost(3)).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateArrayWithMinMaxItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + schema.minItems = 2; + schema.maxItems = 5; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atLeast(2)).generate(any(OasSchema.class)); + verify(mockContext, atMost(5)).generate(any(OasSchema.class)); + } + + @Test(expectedExceptions = UnsupportedOperationException.class) + public void testGenerateArrayWithUnsupportedItems() { + Oas30Schema schema = new Oas30Schema(); + schema.items = new Object(); // Unsupported items type + + generator.generate(mockContext, schema); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java new file mode 100644 index 0000000000..ab2da578e7 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java @@ -0,0 +1,90 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.assertArg; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.Collections; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomCompositeGeneratorTest { + + private RandomCompositeGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder builderSpy; + + @BeforeMethod + public void setUp() { + generator = new RandomCompositeGenerator(); + mockContext = mock(RandomContext.class); + builderSpy = spy(new RandomModelBuilder(true)); + + when(mockContext.getRandomModelBuilder()).thenReturn(builderSpy); + } + + @Test + public void testHandlesCompositeSchema() { + Oas30Schema schema = new Oas30Schema(); + schema.allOf = Collections.singletonList(new Oas30Schema()); + + assertTrue(generator.handles(schema)); + } + + @Test + public void testGenerateAllOf() { + Oas30Schema schema = new Oas30Schema(); + schema.allOf = List.of(new Oas30Schema(), new Oas30Schema(), new Oas30Schema()); + + generator.generate(mockContext, schema); + + verify(builderSpy).object(any()); + verify(mockContext).generate(schema.allOf.get(0)); + verify(mockContext).generate(schema.allOf.get(1)); + verify(mockContext).generate(schema.allOf.get(2)); + } + + @Test + public void testGenerateAnyOf() { + Oas30Schema schema = new Oas30Schema(); + schema.anyOf = List.of(new Oas30Schema(), new Oas30Schema(), new Oas30Schema()); + + generator.generate(mockContext, schema); + + verify(builderSpy).object(any()); + verify(mockContext, atLeast(1)).generate(assertArg(arg -> schema.anyOf.contains(arg))); + verify(mockContext, atMost(3)).generate(assertArg(arg -> schema.anyOf.contains(arg))); + } + + @Test + public void testGenerateOneOf() { + Oas30Schema schema = new Oas30Schema(); + schema.oneOf = List.of(new Oas30Schema(), new Oas30Schema(), new Oas30Schema()); + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).object(any()); + verify(mockContext).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateWithNoCompositeSchema() { + Oas30Schema schema = new Oas30Schema(); + + generator.generate(mockContext, schema); + + verify(builderSpy, never()).object(any()); + verify(mockContext, never()).generate(any(OasSchema.class)); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java new file mode 100644 index 0000000000..78d47bb52e --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java @@ -0,0 +1,76 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertSame; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.HashMap; +import java.util.Map; +import org.citrusframework.openapi.OpenApiSpecification; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomContextTest { + + private OpenApiSpecification specificationMock; + + private RandomContext randomContext; + + private Map schemaDefinitions; + + @BeforeMethod + public void setUp() { + RandomModelBuilder randomModelBuilderMock = mock(); + specificationMock = mock(); + + schemaDefinitions =new HashMap<>(); + + randomContext = spy(new RandomContext(specificationMock, true)); + ReflectionTestUtils.setField(randomContext, "randomModelBuilder", randomModelBuilderMock); + + doReturn(schemaDefinitions).when(randomContext).getSchemaDefinitions(); + } + + @Test + public void testGenerateWithResolvedSchema() { + OasSchema oasSchema = new Oas30Schema(); + randomContext.generate(oasSchema); + verify(randomContext).doGenerate(oasSchema); + } + + @Test + public void testGenerateWithReferencedSchema() { + OasSchema referencedSchema = new Oas30Schema(); + schemaDefinitions.put("reference", referencedSchema); + OasSchema oasSchema = new Oas30Schema(); + oasSchema.$ref = "reference"; + + randomContext.generate(oasSchema); + verify(randomContext).doGenerate(referencedSchema); + } + + @Test + public void testGetRandomModelBuilder() { + assertNotNull(randomContext.getRandomModelBuilder()); + } + + @Test + public void testGetSpecification() { + assertEquals(randomContext.getSpecification(), specificationMock); + } + + @Test + public void testCacheVariable() { + HashMap cachedValue1 = randomContext.get("testKey", k -> new HashMap<>()); + HashMap cachedValue2 = randomContext.get("testKey", k -> new HashMap<>()); + + assertSame(cachedValue1, cachedValue2); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomElementTest.java similarity index 98% rename from connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java rename to connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomElementTest.java index 2add7086c8..0f17bd143e 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomElementTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomElementTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.citrusframework.openapi.util; +package org.citrusframework.openapi.random; import static org.testng.Assert.assertEquals; diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java new file mode 100644 index 0000000000..8d4d7a14e9 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java @@ -0,0 +1,77 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomEnumGeneratorTest { + + private RandomEnumGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder mockBuilder; + private OasSchema mockSchema; + + @BeforeMethod + public void setUp() { + generator = new RandomEnumGenerator(); + mockContext = mock(RandomContext.class); + mockBuilder = mock(RandomModelBuilder.class); + mockSchema = mock(OasSchema.class); + + when(mockContext.getRandomModelBuilder()).thenReturn(mockBuilder); + } + + @Test + public void testHandlesWithEnum() { + mockSchema.enum_ = List.of("value1", "value2", "value3"); + + boolean result = generator.handles(mockSchema); + + assertTrue(result); + } + + @Test + public void testHandlesWithoutEnum() { + mockSchema.enum_ = null; + + boolean result = generator.handles(mockSchema); + + assertFalse(result); + } + + @Test + public void testGenerateWithEnum() { + mockSchema.enum_ = List.of("value1", "value2", "value3"); + + generator.generate(mockContext, mockSchema); + + verify(mockBuilder).appendSimpleQuoted("citrus:randomEnumValue('value1','value2','value3')"); + } + + @Test + public void testGenerateWithEmptyEnum() { + mockSchema.enum_ = List.of(); + + generator.generate(mockContext, mockSchema); + + verify(mockBuilder).appendSimpleQuoted("citrus:randomEnumValue()"); + } + + @Test + public void testGenerateWithNullEnum() { + mockSchema.enum_ = null; + + generator.generate(mockContext, mockSchema); + + verify(mockBuilder, never()).appendSimpleQuoted(anyString()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java new file mode 100644 index 0000000000..01d1c253e6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java @@ -0,0 +1,92 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.function.BiConsumer; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + + +public class RandomGeneratorBuilderTest { + + private BiConsumer consumerMock; + private RandomContext contextMock; + private OasSchema schemaMock; + + @BeforeMethod + public void setUp() { + consumerMock = mock(); + contextMock = mock(); + schemaMock = mock(); + } + + @Test + public void testBuilderWithTypeAndFormat() { + String type = "type1"; + String format = "format1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder(type, format).build(consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.type, type); + assertEquals(schema.format, format); + } + + @Test + public void testBuilderWithType() { + String type = "type1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder().withType(type).build( + consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.type, type); + } + + @Test + public void testBuilderWithFormat() { + String format = "format1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder().withFormat(format).build( + consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.format, format); + } + + @Test + public void testBuilderWithPattern() { + String pattern = "pattern1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder().withPattern(pattern).build( + consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.pattern, pattern); + } + + @Test + public void testBuilderWithEnum() { + RandomGenerator generator = RandomGeneratorBuilder.builder().withEnum().build(consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertNotNull(schema.enum_); + assertTrue(schema.enum_.isEmpty()); + } + + @Test + public void testBuildGenerator() { + RandomGenerator generator = RandomGeneratorBuilder.builder().build(consumerMock); + + generator.generate(contextMock, schemaMock); + + verify(consumerMock).accept(contextMock, schemaMock); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java new file mode 100644 index 0000000000..c788fff730 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java @@ -0,0 +1,152 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomGeneratorTest { + + private RandomGenerator generator; + private OasSchema mockSchema; + + @BeforeMethod + public void setUp() { + mockSchema = mock(OasSchema.class); + generator = new RandomGenerator(mockSchema) { + @Override + void generate(RandomContext randomContext, OasSchema schema) { + // Implementation not needed for this test + } + }; + } + + @Test + public void testHandlesWithMatchingTypeAndFormat() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format1"; + + mockSchema.type = "type1"; + mockSchema.format = "format1"; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithTypeAny() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format1"; + + mockSchema.type = RandomGenerator.ANY; + mockSchema.format = "format1"; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithFormatAny() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format1"; + + mockSchema.type = "type1"; + mockSchema.format = RandomGenerator.ANY; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithPatternAny() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.pattern = "pattern1"; + + mockSchema.type = "type1"; + mockSchema.pattern = RandomGenerator.ANY; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithMatchingPattern() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.pattern = "pattern1"; + + mockSchema.type = "type1"; + mockSchema.pattern = "pattern1"; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithMatchingEnum() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.enum_ = List.of("value1", "value2"); + + mockSchema.type = "type1"; + mockSchema.enum_ = List.of("value1", "value2"); + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithNonMatchingType() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type2"; + otherSchema.format = "format1"; + + mockSchema.type = "type1"; + mockSchema.format = "format1"; + + assertFalse(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithNonMatchingFormat() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format2"; + + mockSchema.type = "type1"; + mockSchema.format = "format1"; + + assertFalse(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithNullSchema() { + assertFalse(generator.handles(null)); + } + + @Test + public void testHandlesWithNullGeneratorSchema() { + RandomGenerator generatorWithNullSchema = new RandomGenerator() { + @Override + void generate(RandomContext randomContext, OasSchema schema) { + // Do nothing + } + }; + + assertFalse(generatorWithNullSchema.handles(mockSchema)); + } + + @Test + public void testNullGenerator() { + RandomContext mockContext = mock(RandomContext.class); + + RandomGenerator.NULL_GENERATOR.generate(mockContext, mockSchema); + + verify(mockContext, never()).getRandomModelBuilder(); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomModelBuilderTest.java similarity index 74% rename from connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java rename to connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomModelBuilderTest.java index f7570c2083..ef4deb3abc 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/util/RandomModelBuilderTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomModelBuilderTest.java @@ -14,32 +14,58 @@ * limitations under the License. */ -package org.citrusframework.openapi.util; +package org.citrusframework.openapi.random; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.expectThrows; + +import java.util.ArrayDeque; +import org.springframework.test.util.ReflectionTestUtils; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import static org.testng.Assert.*; - public class RandomModelBuilderTest { private RandomModelBuilder builder; @BeforeMethod public void setUp() { - builder = new RandomModelBuilder(); + builder = new RandomModelBuilder(true); } @Test public void testInitialState() { - String text = builder.toString(); + String text = builder.write(); assertEquals(text, ""); } @Test public void testAppendSimple() { builder.appendSimple("testValue"); - String json = builder.toString(); + String json = builder.write(); + assertEquals(json, "testValue"); + } + + @Test + public void testAppendSimpleToEmptyQueue() { + ReflectionTestUtils.setField(builder, "deque", new ArrayDeque<>()); + builder.appendSimple("testValue"); + String json = builder.write(); + assertEquals(json, "testValue"); + } + + @Test + public void testAppendSimpleQuoted() { + builder.appendSimpleQuoted("testValue"); + String json = builder.write(); + assertEquals(json, "\"testValue\""); + } + + @Test + public void testAppendSimpleQuotedIfNotQuoting() { + ReflectionTestUtils.setField(builder,"quote", false); + builder.appendSimpleQuoted("testValue"); + String json = builder.write(); assertEquals(json, "testValue"); } @@ -49,7 +75,7 @@ public void testObjectWithProperties() { builder.property("key1", () -> builder.appendSimple("\"value1\"")); builder.property("key2", () -> builder.appendSimple("\"value2\"")); }); - String json = builder.toString(); + String json = builder.write(); assertEquals(json, "{\"key1\": \"value1\",\"key2\": \"value2\"}"); } @@ -60,7 +86,7 @@ public void testNestedObject() { builder.property("innerKey", () -> builder.appendSimple("\"innerValue\"")) )) ); - String json = builder.toString(); + String json = builder.write(); assertEquals(json, "{\"outerKey\": {\"innerKey\": \"innerValue\"}}"); } @@ -71,7 +97,7 @@ public void testArray() { builder.appendSimple("\"value2\""); builder.appendSimple("\"value3\""); }); - String json = builder.toString(); + String json = builder.write(); assertEquals(json, "[\"value1\",\"value2\",\"value3\"]"); } @@ -85,7 +111,7 @@ public void testNestedArray() { }); builder.appendSimple("\"value2\""); }); - String json = builder.toString(); + String json = builder.write(); assertEquals(json, "[\"value1\",[\"nestedValue1\",\"nestedValue2\"],\"value2\"]"); } @@ -100,7 +126,7 @@ public void testMixedStructure() { })); builder.property("key2", () -> builder.appendSimple("\"value2\"")); }); - String json = builder.toString(); + String json = builder.write(); assertEquals(json, "{\"key1\": [\"value1\",{\"nestedKey\": \"nestedValue\"}],\"key2\": \"value2\"}"); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java new file mode 100644 index 0000000000..fbcb4f0479 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java @@ -0,0 +1,217 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.math.BigDecimal; +import org.citrusframework.openapi.OpenApiConstants; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class RandomNumberGeneratorTest { + + private RandomNumberGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder mockBuilder; + private OasSchema schema; + + @BeforeMethod + public void setUp() { + generator = new RandomNumberGenerator(); + mockContext = mock(RandomContext.class); + mockBuilder = mock(RandomModelBuilder.class); + schema = new Oas30Schema(); + + when(mockContext.getRandomModelBuilder()).thenReturn(mockBuilder); + } + + @Test + public void testGenerateDefaultBounds() { + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '-1000', '1000', 'false', 'false')"); + } + + @Test + public void testGenerateWithMinimum() { + schema.minimum = BigDecimal.valueOf(5); + generator.generate(mockContext, schema); + // Max is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '5', '1005', 'false', 'false')"); + } + + @Test + public void testGenerateWithMaximum() { + schema.maximum = BigDecimal.valueOf(15); + generator.generate(mockContext, schema); + // Min is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '-985', '15', 'false', 'false')"); + } + + @Test + public void testGenerateWithMinimumAndMaximum() { + schema.minimum = BigDecimal.valueOf(5); + schema.maximum = BigDecimal.valueOf(15); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '5', '15', 'false', 'false')"); + } + + @Test + public void testGenerateWithExclusiveMinimum() { + schema.minimum = BigDecimal.valueOf(5); + schema.exclusiveMinimum = true; + generator.generate(mockContext, schema); + // Max is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '5', '1005', 'true', 'false')"); + } + + @Test + public void testGenerateWithExclusiveMaximum() { + schema.maximum = BigDecimal.valueOf(15); + schema.exclusiveMaximum = true; + generator.generate(mockContext, schema); + // Min is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '-985', '15', 'false', 'true')"); + } + + @Test + public void testGenerateWithMultipleOf() { + schema.multipleOf = BigDecimal.valueOf(5); + schema.minimum = BigDecimal.valueOf(10); + schema.maximum = BigDecimal.valueOf(50); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('0', '10', '50', 'false', 'false', '5')"); + } + + @Test + public void testGenerateWithIntegerType() { + schema.type = "integer"; + schema.minimum = BigDecimal.valueOf(1); + schema.maximum = BigDecimal.valueOf(10); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('0', '1', '10', 'false', 'false')"); + } + + @Test + public void testGenerateWithFloatType() { + schema.type = "number"; + schema.minimum = BigDecimal.valueOf(1.5); + schema.maximum = BigDecimal.valueOf(10.5); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '1.5', '10.5', 'false', 'false')"); + } + + @Test + public void testGenerateWithMultipleOfFloat() { + schema.type = "number"; + schema.multipleOf = BigDecimal.valueOf(0.5); + schema.minimum = BigDecimal.valueOf(1.0); + schema.maximum = BigDecimal.valueOf(5.0); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('1', '1.0', '5.0', 'false', 'false', '0.5')"); + } + + @Test + public void testCalculateMinRelativeToMaxWithMultipleOf() { + BigDecimal max = new BigDecimal("1000"); + Number multipleOf = new BigDecimal("10"); + + BigDecimal result = RandomNumberGenerator.calculateMinRelativeToMax(max, multipleOf); + + BigDecimal expected = max.subtract(new BigDecimal(multipleOf.toString()).abs().multiply(RandomNumberGenerator.HUNDRED)); + assertEquals(result, expected); + } + + @Test + public void testCalculateMinRelativeToMaxWithoutMultipleOf() { + BigDecimal max = new BigDecimal("1000"); + + BigDecimal result = RandomNumberGenerator.calculateMinRelativeToMax(max, null); + + BigDecimal expected = max.subtract(max.multiply(BigDecimal.valueOf(2)).max(RandomNumberGenerator.THOUSAND)); + assertEquals(result, expected); + } + + @Test + public void testCalculateMaxRelativeToMinWithMultipleOf() { + BigDecimal min = new BigDecimal("1000"); + Number multipleOf = new BigDecimal("10"); + + BigDecimal result = RandomNumberGenerator.calculateMaxRelativeToMin(min, multipleOf); + + BigDecimal expected = min.add(new BigDecimal(multipleOf.toString()).abs().multiply(RandomNumberGenerator.HUNDRED)); + assertEquals(result, expected); + } + + @Test + public void testCalculateMaxRelativeToMinWithoutMultipleOf() { + BigDecimal min = new BigDecimal("1000"); + + BigDecimal result = RandomNumberGenerator.calculateMaxRelativeToMin(min, null); + + BigDecimal expected = min.add(min.multiply(BigDecimal.valueOf(2)).max(RandomNumberGenerator.THOUSAND)); + assertEquals(result, expected); + } + + @Test + public void testHandlesWithIntegerType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_INTEGER; + + assertTrue(generator.handles(schema)); + } + + @Test + public void testHandlesWithNumberType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_NUMBER; + + assertTrue(generator.handles(schema)); + } + + @Test + public void testHandlesWithOtherType() { + OasSchema schema = new Oas30Schema(); + schema.type = "string"; + + assertFalse(generator.handles(schema)); + } + + @Test + public void testHandlesWithNullType() { + OasSchema schema = new Oas30Schema(); + schema.type = null; + + assertFalse(generator.handles(schema)); + } + + @Test + public void testHandlesWithNullSchema() { + assertFalse(generator.handles(null)); + } + + @DataProvider(name = "findLeastSignificantDecimalPlace") + public static Object[][] findLeastSignificantDecimalPlace() { + return new Object[][]{ + {new BigDecimal("1234.5678"), 4}, + {new BigDecimal("123.567"), 3}, + {new BigDecimal("123.56"), 2}, + {new BigDecimal("123.5"), 1}, + {new BigDecimal("123.0"), 0}, + {new BigDecimal("123"), 0} + }; + } + + @Test(dataProvider = "findLeastSignificantDecimalPlace") + void findLeastSignificantDecimalPlace(BigDecimal number, int expectedSignificance) { + assertEquals(generator.findLeastSignificantDecimalPlace(number), + expectedSignificance); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java new file mode 100644 index 0000000000..a32809f2f9 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java @@ -0,0 +1,134 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import org.citrusframework.openapi.OpenApiConstants; +import org.citrusframework.openapi.OpenApiSpecification; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomObjectGeneratorTest { + + private RandomObjectGenerator generator; + private RandomContext contextMock; + private RandomModelBuilder randomModelBuilderSpy; + private OpenApiSpecification specificationMock; + + @BeforeMethod + public void setUp() { + generator = new RandomObjectGenerator(); + contextMock = mock(); + specificationMock = mock(); + + randomModelBuilderSpy = spy(new RandomModelBuilder(true)); + when(contextMock.getRandomModelBuilder()).thenReturn(randomModelBuilderSpy); + when(contextMock.getSpecification()).thenReturn(specificationMock); + when(contextMock.get(eq("OBJECT_STACK"), any())).thenReturn(new ArrayDeque<>()); + + } + + @Test + public void testHandlesObjectType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + + assertTrue(generator.handles(schema)); + } + + @Test + public void testDoesNotHandleNonObjectType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_STRING; + + assertFalse(generator.handles(schema)); + } + + @Test + public void testGenerateObjectWithoutProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + } + + @Test + public void testGenerateObjectWithProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + schema.properties = new HashMap<>(); + OasSchema propertySchema = new Oas30Schema(); + schema.properties.put("property1", propertySchema); + + when(specificationMock.isGenerateOptionalFields()).thenReturn(true); + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + verify(randomModelBuilderSpy).property(eq("property1"), any()); + verify(contextMock).generate(propertySchema); + } + + @Test + public void testGenerateObjectWithRequiredProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + schema.properties = new HashMap<>(); + OasSchema propertySchema = new Oas30Schema(); + schema.properties.put("property1", propertySchema); + schema.required = List.of("property1"); + + when(specificationMock.isGenerateOptionalFields()).thenReturn(false); + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + verify(randomModelBuilderSpy).property(eq("property1"), any()); + verify(contextMock).generate(propertySchema); + } + + @Test + public void testGenerateObjectWithOptionalProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + schema.properties = new HashMap<>(); + OasSchema propertySchema = new Oas30Schema(); + schema.properties.put("property1", propertySchema); + schema.required = List.of(); + when(specificationMock.isGenerateOptionalFields()).thenReturn(false); + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + verify(randomModelBuilderSpy, never()).property(eq("property1"), any()); + verify(contextMock, never()).generate(propertySchema); + } + + @Test + public void testGenerateObjectWithRecursion() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + Deque objectStack = new ArrayDeque<>(); + objectStack.push(schema); + + when(contextMock.get(eq("OBJECT_STACK"), any())).thenReturn(objectStack); + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy, never()).object(any()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java new file mode 100644 index 0000000000..445a318bd9 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java @@ -0,0 +1,70 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomStringGeneratorTest { + + private RandomStringGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder mockBuilder; + private OasSchema schema; + + @BeforeMethod + public void setUp() { + generator = new RandomStringGenerator(); + mockContext = mock(RandomContext.class); + mockBuilder = mock(RandomModelBuilder.class); + schema = new Oas30Schema(); + + when(mockContext.getRandomModelBuilder()).thenReturn(mockBuilder); + } + + @Test + public void testGenerateDefaultLength() { + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,1)"); + } + + @Test + public void testGenerateWithMinLength() { + schema.minLength = 5; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,5)"); + } + + @Test + public void testGenerateWithMaxLength() { + schema.maxLength = 15; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(15,MIXED,true,1)"); + } + + @Test + public void testGenerateWithMinAndMaxLength() { + schema.minLength = 3; + schema.maxLength = 8; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(8,MIXED,true,3)"); + } + + @Test + public void testGenerateWithZeroMinLength() { + schema.minLength = 0; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,1)"); + } + + @Test + public void testGenerateWithZeroMaxLength() { + schema.maxLength = 0; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,1)"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java index 80d901395d..04a0d47086 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java @@ -85,7 +85,7 @@ public void shouldValidateHttpMessage() { processor.validate(httpMessageMock, contextMock); - verify(openApiRequestValidatorSpy, times(1)).validateRequest(operationPathAdapterMock, httpMessageMock); + verify(openApiRequestValidatorSpy).validateRequest(operationPathAdapterMock, httpMessageMock); } @Test @@ -100,7 +100,7 @@ public void shouldCallValidateRequest() { processor.validate(httpMessageMock, contextMock); - verify(openApiSpecificationMock, times(1)).getOperation(anyString(), + verify(openApiSpecificationMock).getOperation(anyString(), any(TestContext.class)); verify(openApiRequestValidatorSpy, times(0)).validateRequest(operationPathAdapterMock, httpMessageMock); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java index f79a8a9887..3716b503c7 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java @@ -19,7 +19,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; @@ -113,8 +112,8 @@ public void shouldValidateRequestWithNoErrors() { openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); // Then - verify(openApiInteractionValidatorMock, times(1)).validateRequest(any(Request.class)); - verify(validationReportMock, times(1)).hasErrors(); + verify(openApiInteractionValidatorMock).validateRequest(any(Request.class)); + verify(validationReportMock).hasErrors(); } @Test(expectedExceptions = ValidationException.class) @@ -131,8 +130,8 @@ public void shouldValidateRequestWithErrors() { openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); // Then - verify(openApiInteractionValidatorMock, times(1)).validateRequest(any(Request.class)); - verify(validationReportMock, times(1)).hasErrors(); + verify(openApiInteractionValidatorMock).validateRequest(any(Request.class)); + verify(validationReportMock).hasErrors(); } @Test diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java index 671560ba9f..2058af2558 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java @@ -85,7 +85,7 @@ public void shouldCallValidateResponse() { processor.validate(httpMessageMock, contextMock); - verify(openApiResponseValidatorSpy, times(1)).validateResponse(operationPathAdapterMock, httpMessageMock); + verify(openApiResponseValidatorSpy).validateResponse(operationPathAdapterMock, httpMessageMock); } @Test @@ -100,7 +100,7 @@ public void shouldNotValidateWhenNoOperation() { processor.validate(httpMessageMock, contextMock); - verify(openApiSpecificationMock, times(1)).getOperation(anyString(), + verify(openApiSpecificationMock).getOperation(anyString(), any(TestContext.class)); verify(openApiResponseValidatorSpy, times(0)).validateResponse(operationPathAdapterMock, httpMessageMock); } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java index e59f1f2821..cfccf76d92 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java @@ -16,11 +16,24 @@ package org.citrusframework.openapi.validation; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request.Method; import com.atlassian.oai.validator.model.Response; import com.atlassian.oai.validator.report.ValidationReport; import io.apicurio.datamodels.openapi.models.OasOperation; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; import org.citrusframework.exceptions.ValidationException; import org.citrusframework.http.message.HttpMessage; import org.citrusframework.openapi.OpenApiSpecification; @@ -34,21 +47,6 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertTrue; - public class OpenApiResponseValidatorTest { @Mock @@ -120,8 +118,8 @@ public void shouldValidateWithNoErrors() { openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); // Then - verify(openApiInteractionValidatorMock, times(1)).validateResponse(anyString(), any(Method.class), any(Response.class)); - verify(validationReportMock, times(1)).hasErrors(); + verify(openApiInteractionValidatorMock).validateResponse(anyString(), any(Method.class), any(Response.class)); + verify(validationReportMock).hasErrors(); } @Test(expectedExceptions = ValidationException.class) @@ -141,8 +139,8 @@ public void shouldValidateWithErrors() { openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); // Then - verify(openApiInteractionValidatorMock, times(1)).validateResponse(anyString(), any(Method.class), any(Response.class)); - verify(validationReportMock, times(1)).hasErrors(); + verify(openApiInteractionValidatorMock).validateResponse(anyString(), any(Method.class), any(Response.class)); + verify(validationReportMock).hasErrors(); } @Test diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java index 5441e3f13f..1613fc19ce 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java @@ -70,7 +70,7 @@ public DefaultFunctionLibrary() { getMembers().put("randomNumber", new RandomNumberFunction()); getMembers().put("randomNumberGenerator", new AdvancedRandomNumberFunction()); getMembers().put("randomString", new RandomStringFunction()); - getMembers().put("randomValue", new RandomPatternFunction()); + getMembers().put("randomPattern", new RandomPatternFunction()); getMembers().put("concat", new ConcatFunction()); getMembers().put("currentDate", new CurrentDateFunction()); getMembers().put("substring", new SubstringFunction()); diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java index bc7252e057..6cde8506c4 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java @@ -21,8 +21,9 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.List; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.exceptions.InvalidFunctionUsageException; import org.citrusframework.functions.Function; @@ -37,71 +38,72 @@ *
  • Min value: The minimum value for the generated random number (optional, default: Double.MIN_VALUE).
  • *
  • Max value: The maximum value for the generated random number (optional, default: Double.MAX_VALUE).
  • *
  • Exclude min: Whether to exclude the minimum value (optional, default: false).
  • - *
  • Exclude man: Whether to exclude the maximum value (optional, default: false).
  • + *
  • Exclude max: Whether to exclude the maximum value (optional, default: false).
  • + *
  • Multiple of: The generated number will be a multiple of this value (optional).
  • * *

    - * This function differs from the {@link RandomNumberFunction} in several key ways: - *

      + * This function differs from the {@link RandomNumberFunction} in several key ways: + *
        *
      • It allows to specify several aspects of a number (see above).
      • *
      • The length of the number is restricted to the range and precision of a double, whereas RandomNumberFunction can create arbitrarily long integer values.
      • - *
      + *
    */ public class AdvancedRandomNumberFunction implements Function { - /** - * Basic seed generating random number - */ - private static final Random generator = new Random(System.currentTimeMillis()); + public static final BigDecimal DEFAULT_MAX_VALUE = new BigDecimal(1000000); + public static final BigDecimal DEFAULT_MIN_VALUE = DEFAULT_MAX_VALUE.negate(); public String execute(List parameterList, TestContext context) { - int decimalPlaces = 0; - double minValue = -1000000; - double maxValue = 1000000; - boolean excludeMin = false; - boolean excludeMax = false; - if (parameterList == null) { throw new InvalidFunctionUsageException("Function parameters must not be null."); } - if (!parameterList.isEmpty()) { - decimalPlaces = parseParameter(1, parameterList.get(0), Integer.class, - Integer::parseInt); - if (decimalPlaces < 0) { - throw new InvalidFunctionUsageException( - "Invalid parameter definition. Decimal places must be a non-negative integer value."); - } + int decimalPlaces = getParameter(parameterList, 0, Integer.class, Integer::parseInt, 2); + if (decimalPlaces < 0) { + throw new InvalidFunctionUsageException( + "Decimal places must be a non-negative integer value."); } - if (parameterList.size() > 1) { - minValue = parseParameter(2, parameterList.get(1), Double.class, Double::parseDouble); + BigDecimal minValue = getParameter(parameterList, 1, BigDecimal.class, BigDecimal::new, + DEFAULT_MIN_VALUE); + BigDecimal maxValue = getParameter(parameterList, 2, BigDecimal.class, BigDecimal::new, + DEFAULT_MAX_VALUE); + if (minValue.compareTo(maxValue) > 0) { + throw new InvalidFunctionUsageException("Min value must be less than max value."); } - if (parameterList.size() > 2) { - maxValue = parseParameter(3, parameterList.get(2), Double.class, Double::parseDouble); - if (minValue > maxValue) { - throw new InvalidFunctionUsageException( - "Invalid parameter definition. Min value must be less than max value."); - } - } + boolean excludeMin = getParameter(parameterList, 3, Boolean.class, Boolean::parseBoolean, + false); + boolean excludeMax = getParameter(parameterList, 4, Boolean.class, Boolean::parseBoolean, + false); + BigDecimal multiple = getParameter(parameterList, 5, BigDecimal.class, BigDecimal::new, + null); - if (parameterList.size() > 3) { - excludeMin = parseParameter(4, parameterList.get(3), Boolean.class, - Boolean::parseBoolean); - } + return getRandomNumber(decimalPlaces, minValue, maxValue, excludeMin, excludeMax, multiple); + } - if (parameterList.size() > 4) { - excludeMax = parseParameter(5, parameterList.get(4), Boolean.class, - Boolean::parseBoolean); + private T getParameter(List params, int index, Class type, + java.util.function.Function parser, T defaultValue) { + if (index < params.size()) { + String param = params.get(index); + return "null".equals(param) ? defaultValue + : parseParameter(index + 1, param, type, parser); } - - return getRandomNumber(decimalPlaces, minValue, maxValue, excludeMin, excludeMax); + return defaultValue; } private T parseParameter(int index, String text, Class type, java.util.function.Function parseFunction) { + T value; try { - return parseFunction.apply(text); + + value = parseFunction.apply(text); + if (value == null) { + throw new CitrusRuntimeException( + "Text '%s' could not be parsed to '%s'. Resulting value is null".formatted(text, + type.getSimpleName())); + } + return value; } catch (Exception e) { throw new InvalidFunctionUsageException( format("Invalid parameter at index %d. %s must be parsable to %s.", index, text, @@ -112,33 +114,114 @@ private T parseParameter(int index, String text, Class type, /** * Static number generator method. */ - private String getRandomNumber(int decimalPlaces, double minValue, double maxValue, - boolean excludeMin, boolean excludeMax) { - double adjustment = Math.pow(10, -decimalPlaces); + private String getRandomNumber(int decimalPlaces, BigDecimal minValue, BigDecimal maxValue, + boolean excludeMin, boolean excludeMax, BigDecimal multiple) { - if (excludeMin) { - minValue += adjustment; - } + minValue = excludeMin ? incrementToExclude(minValue) : minValue; + maxValue = excludeMax ? decrementToExclude(maxValue) : maxValue; - if (excludeMax) { - maxValue -= adjustment; - } + BigDecimal range = maxValue.subtract(minValue); - BigDecimal range = BigDecimal.valueOf(maxValue).subtract(BigDecimal.valueOf(minValue)); + BigDecimal randomValue; + if (multiple != null) { + randomValue = createMultipleOf(minValue, maxValue, multiple); + } else { + randomValue = createRandomValue(minValue, range, + ThreadLocalRandom.current().nextDouble()); + randomValue = randomValue.setScale(decimalPlaces, RoundingMode.HALF_UP); + } - double randomValue = getRandomValue(minValue, range, generator.nextDouble()); - BigDecimal bd = new BigDecimal(Double.toString(randomValue)); - bd = bd.setScale(2, RoundingMode.HALF_UP); + if (randomValue == null) { + // May only happen if multiple is out of range of min/max + return format("%s", Double.POSITIVE_INFINITY); + } return decimalPlaces == 0 ? - format("%s", bd.longValue()) : - format(format("%%.%sf", decimalPlaces), bd.doubleValue()); + format("%s", randomValue.longValue()) : + format(format("%%.%sf", decimalPlaces), randomValue.doubleValue()); } - double getRandomValue(double minValue, BigDecimal range, double random) { + // Pass in random for testing + BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) { BigDecimal offset = range.multiply(BigDecimal.valueOf(random)); - BigDecimal value = BigDecimal.valueOf(minValue).add(offset); - return value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0 ? Double.MAX_VALUE : value.doubleValue(); + BigDecimal value = minValue.add(offset); + return value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0 ? BigDecimal.valueOf( + Double.MAX_VALUE) : value; + } + + private BigDecimal largestMultipleOf(BigDecimal highest, BigDecimal multipleOf) { + RoundingMode roundingMode = + highest.compareTo(BigDecimal.ZERO) < 0 ? RoundingMode.UP : RoundingMode.DOWN; + BigDecimal factor = highest.divide(multipleOf, 0, roundingMode); + return multipleOf.multiply(factor); + } + + private BigDecimal lowestMultipleOf(BigDecimal lowest, BigDecimal multipleOf) { + RoundingMode roundingMode = + lowest.compareTo(java.math.BigDecimal.ZERO) < 0 ? RoundingMode.DOWN : RoundingMode.UP; + BigDecimal factor = lowest.divide(multipleOf, 0, roundingMode); + return multipleOf.multiply(factor); + } + + private BigDecimal incrementToExclude(BigDecimal val) { + return val.add(determineIncrement(val)) + .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN); + } + + private BigDecimal decrementToExclude(BigDecimal val) { + return val.subtract(determineIncrement(val)) + .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN); + } + + private BigDecimal determineIncrement(BigDecimal number) { + return java.math.BigDecimal.valueOf( + 1.0d / (Math.pow(10d, findLeastSignificantDecimalPlace(number)))); + } + + private int findLeastSignificantDecimalPlace(BigDecimal number) { + number = number.stripTrailingZeros(); + + String[] parts = number.toPlainString().split("\\."); + + if (parts.length == 1) { + return 0; + } + + return parts[1].length(); + } + + private BigDecimal createMultipleOf( + BigDecimal minimum, + BigDecimal maximum, + BigDecimal multipleOf + ) { + + BigDecimal lowestMultiple = lowestMultipleOf(minimum, multipleOf); + BigDecimal largestMultiple = largestMultipleOf(maximum, multipleOf); + + // Check if there are no valid multiples in the range + if (lowestMultiple.compareTo(largestMultiple) > 0) { + return null; + } + + BigDecimal range = largestMultiple.subtract(lowestMultiple) + .divide(multipleOf, RoundingMode.DOWN); + + // Don't go for incredible large numbers + if (range.compareTo(BigDecimal.valueOf(11)) > 0) { + range = BigDecimal.valueOf(10); + } + + long factor = 0; + if (range.compareTo(BigDecimal.ZERO) != 0) { + factor = ThreadLocalRandom.current().nextLong(1, range.longValue() + 1); + } + BigDecimal randomMultiple = lowestMultiple.add( + multipleOf.multiply(BigDecimal.valueOf(factor))); + randomMultiple = randomMultiple.setScale(findLeastSignificantDecimalPlace(multipleOf), + RoundingMode.HALF_UP); + + return randomMultiple; } } \ No newline at end of file diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java index 4720921c7e..377b2d5fe7 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java @@ -25,9 +25,8 @@ import org.citrusframework.functions.Function; /** - * The RandomPatternFunction class implements the Function interface. This function generates a - * random string based on a provided regular expression pattern. It uses the Generex library to - * generate the random string. + * The RandomPatternFunction generates a random string based on a provided regular expression pattern. + * It uses the Generex library to generate the random string. *

    * Note: The Generex library has limitations in its ability to generate all possible expressions * from a given regular expression. It may not support certain complex regex features or produce all diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java index a1b03ea154..505b1cd707 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java +++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.InvalidFunctionUsageException; import org.citrusframework.functions.Function; @@ -33,7 +34,7 @@ * @author Christoph Deppisch */ public class RandomStringFunction implements Function { - private static Random generator = new Random(System.currentTimeMillis()); + private static final Random generator = new Random(System.currentTimeMillis()); private static final char[] ALPHABET_UPPER = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', @@ -68,12 +69,13 @@ public String execute(List parameterList, TestContext context) { int numberOfLetters; String notationMethod = MIXED; boolean includeNumbers = false; + int minNumberOfLetters = -1; if (parameterList == null || parameterList.isEmpty()) { throw new InvalidFunctionUsageException("Function parameters must not be empty"); } - if (parameterList.size() > 3) { + if (parameterList.size() > 4) { throw new InvalidFunctionUsageException("Too many parameters for function"); } @@ -90,12 +92,16 @@ public String execute(List parameterList, TestContext context) { includeNumbers = parseBoolean(parameterList.get(2)); } + if (parameterList.size() > 3) { + minNumberOfLetters = parseInt(parameterList.get(3)); + } + if (notationMethod.equals(UPPERCASE)) { - return getRandomString(numberOfLetters, ALPHABET_UPPER, includeNumbers); + return getRandomString(numberOfLetters, ALPHABET_UPPER, includeNumbers, minNumberOfLetters); } else if (notationMethod.equals(LOWERCASE)) { - return getRandomString(numberOfLetters, ALPHABET_LOWER, includeNumbers); + return getRandomString(numberOfLetters, ALPHABET_LOWER, includeNumbers, minNumberOfLetters); } else { - return getRandomString(numberOfLetters, ALPHABET_MIXED, includeNumbers); + return getRandomString(numberOfLetters, ALPHABET_MIXED, includeNumbers, minNumberOfLetters); } } @@ -106,7 +112,7 @@ public String execute(List parameterList, TestContext context) { * @param includeNumbers * @return */ - public static String getRandomString(int numberOfLetters, char[] alphabet, boolean includeNumbers) { + public static String getRandomString(int numberOfLetters, char[] alphabet, boolean includeNumbers, int minNumberOfLetters) { StringBuilder builder = new StringBuilder(); int upperRange = alphabet.length - 1; @@ -118,6 +124,11 @@ public static String getRandomString(int numberOfLetters, char[] alphabet, boole upperRange += NUMBERS.length; } + if (minNumberOfLetters > -1) { + numberOfLetters = ThreadLocalRandom.current() + .nextInt(minNumberOfLetters, numberOfLetters + 1); + } + for (int i = 1; i < numberOfLetters; i++) { int letterIndex = generator.nextInt(upperRange); diff --git a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java index ba715afe48..629e279c19 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java +++ b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java @@ -67,7 +67,7 @@ public static String appendSegmentToUrlPath(String path, String segment) { } public static String quote(String text, boolean quote) { - return quote ? String.format("\"%s\"", text) : text; + return quote ? "\"" + text + "\"" : text; } /** diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java new file mode 100644 index 0000000000..82cf51cfa7 --- /dev/null +++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java @@ -0,0 +1,416 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.functions.core; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.InvalidFunctionUsageException; +import org.testng.Assert; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class AdvancedRandomNumberFunctionTest { + + private AdvancedRandomNumberFunction function; + private TestContext context; + + @BeforeMethod + public void setUp() { + function = new AdvancedRandomNumberFunction(); + context = new TestContext(); + } + + @Test + public void testRandomNumberWithNullParameter() { + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(null, context)); + assertEquals(exception.getMessage(), + "Function parameters must not be null."); + } + + @Test + public void testRandomNumberWithDefaultValues() { + List params = List.of(); + String result = function.execute(params, context); + assertNotNull(result); + assertTrue(result.matches("-?\\d*\\.\\d{2}")); + } + + @Test + public void testRandomNumberWithDecimalPlaces() { + List params = List.of("2"); + String result = function.execute(params, context); + assertNotNull(result); + assertTrue(result.matches("-?\\d*\\.\\d{2}"), "result does not match pattern: " + result); + } + + @Test + public void testRandomNumberWithinRange() { + List params = List.of("2", "10.5", "20.5"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= 10.5 && randomValue <= 20.5); + } + + @Test + public void testRandomNumberIncludesMin() { + List params = List.of("1", "10.5", "20.5"); + function = new AdvancedRandomNumberFunction() { + @Override + BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) { + random = 0.0; + return super.createRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertEquals(result, "10.5"); + } + + @Test + public void testRandomNumberIncludesMax() { + List params = List.of("1", "10.5", "20.5"); + function = new AdvancedRandomNumberFunction() { + @Override + BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) { + random = 1.0; + return super.createRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertEquals(result, "20.5"); + } + + @Test + public void testRandomNumberExcludeMin() { + List params = List.of("1", "10.5", "20.5", "true", "false"); + function = new AdvancedRandomNumberFunction() { + @Override + BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) { + random = 0.0; + return super.createRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue > 10.5 && randomValue <= 20.5); + } + + @Test + public void testRandomNumberExcludeMax() { + List params = List.of("2", "10.5", "20.5", "false", "true"); + function = new AdvancedRandomNumberFunction() { + @Override + BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) { + random = 1.0; + return super.createRandomValue(minValue, range, random); + } + }; + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= 10.5 && randomValue < 20.5); + } + + @Test + public void testRandomInteger32EdgeCase() { + List params = List.of("0", "-2147483648", "2147483647", "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Integer.MAX_VALUE && randomValue < Integer.MAX_VALUE); + } + + @Test + public void testRandomInteger32MinEqualsMaxEdgeCase() { + List params = List.of("0", "3", "3", "false", "false"); + for (int i = 0; i < 100; i++) { + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertEquals(randomValue, 3); + } + } + + // randomDouble('0','3','3','true','true') + // randomDouble('0','3','3','true','true') + + @Test + public void testRandomDouble32MinEqualsMaxEdgeCase() { + List params = List.of("2", "3.0", "3.0", "false", "false"); + for (int i = 0; i < 100; i++) { + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertEquals(randomValue, 3); + } + } + + @Test + public void testRandomInteger64EdgeCase() { + List params = List.of("0", "-9223372036854775808", "9223372036854775807", "false", + "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Long.MAX_VALUE && randomValue < Long.MAX_VALUE); + } + + @Test + public void testRandomNumberFloatEdgeCase() { + List params = List.of("0", "-3.4028235E38", "3.4028235E38", "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Float.MAX_VALUE && randomValue < Float.MAX_VALUE); + } + + @Test + public void testRandomNumberDoubleEdgeCase() { + List params = List.of("0", "-1.7976931348623157E308", "1.7976931348623157E308", + "false", "false"); + String result = function.execute(params, context); + assertNotNull(result); + double randomValue = Double.parseDouble(result); + assertTrue(randomValue >= -Double.MAX_VALUE && randomValue < Double.MAX_VALUE); + } + + @Test + public void testInvalidDecimalPlaces() { + List params = List.of("-1"); // invalid decimalPlaces + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(params, context)); + assertEquals(exception.getMessage(), + "Decimal places must be a non-negative integer value."); + } + + @Test + public void testInvalidRange() { + List params = List.of("2", "20.5", "10.5"); // invalid range + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(params, context)); + assertEquals(exception.getMessage(), + "Min value must be less than max value."); + } + + @Test + public void testInvalidDecimalPlacesFormat() { + List params = List.of("xxx"); // invalid decimalPlaces + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(params, context)); + assertEquals(exception.getMessage(), + "Invalid parameter at index 1. xxx must be parsable to Integer."); + } + + @Test + public void testInvalidMinValueFormat() { + List params = List.of("1", "xxx"); // invalid min value + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(params, context)); + assertEquals(exception.getMessage(), + "Invalid parameter at index 2. xxx must be parsable to BigDecimal."); + } + + @Test + public void testInvalidMaxValueFormat() { + List params = List.of("1", "1.1", "xxx"); // invalid max value + InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, + () -> function.execute(params, context)); + assertEquals(exception.getMessage(), + "Invalid parameter at index 3. xxx must be parsable to BigDecimal."); + } + + @DataProvider(name = "testRandomNumber") + public static Object[][] testRandomNumber() { + return new Object[][]{ + + {0, 12, null, null, false, false}, + {0, null, 0, 2, true, true}, + {0, null, null, null, false, false}, + {0, null, 0, 100, false, false}, + {0, null, 0, 2, false, false}, + {0, null, -100, 0, false, false}, + {0, null, -2, 0, false, false}, + {0, null, 0, 100, true, true}, + {0, null, -100, 0, true, true}, + {0, null, -2, 0, true, true}, + {0, null, 0, null, false, false}, + {0, null, 0, 0, false, false}, + {0, 11, 0, 12, true, true}, + + {0, 13, 0, 100, false, false}, + {0, 14, 0, 14, false, false}, + {0, 15, -100, 0, false, false}, + {0, 16, -16, 0, false, false}, + {0, 17, 0, 100, true, true}, + {0, 18, -100, 0, true, true}, + {0, 19, -20, 0, true, true}, + {0, 20, 0, null, false, false}, + {0, 21, 21, 21, false, false}, + + {0, null, 0, 2, true, true}, + {0, null, null, null, false, false}, + {0, null, 0, 100, false, false}, + {0, null, 0, 2, false, false}, + {0, null, -100, 0, false, false}, + {0, null, -2, 0, false, false}, + {0, null, 0, 100, true, true}, + {0, null, -100, 0, true, true}, + {0, null, -2, 0, true, true}, + {0, null, 0, null, false, false}, + {0, null, 0, 0, false, false}, + {0, 11, 0, 12, true, true}, + {0, 12, null, null, false, false}, + {0, 13, 0, 100, false, false}, + {0, 14, 0, 14, false, false}, + {0, 15, -100, 0, false, false}, + {0, 16, -16, 0, false, false}, + {0, 17, 0, 100, true, true}, + {0, 18, -100, 0, true, true}, + {0, 19, -20, 0, true, true}, + {0, 20, 0, null, false, false}, + {0, 21, 21, 21, false, false}, + + {3, null, 0, 2, true, true}, + {3, null, null, null, false, false}, + {3, null, 0, 100, false, false}, + {3, null, 0, 2, false, false}, + {3, null, -100, 0, false, false}, + {3, null, -2, 0, false, false}, + {3, null, 0, 100, true, true}, + {3, null, -100, 0, true, true}, + {3, null, -2, 0, true, true}, + {3, null, 0, null, false, false}, + {3, null, 0, 0, false, false}, + {3, 11.123f, 0, 13, true, true}, + {3, 12.123f, null, null, false, false}, + {3, 13.123f, 0, 100, false, false}, + {3, 14.123f, 0, 14, false, false}, + {3, 15.123f, -100, 0, false, false}, + {3, 16.123f, -16, 0, false, false}, + {3, 17.123f, 0, 100, true, true}, + {3, 18.123f, -100, 0, true, true}, + {3, 19.123f, -21, 0, true, true}, + {3, 20.123f, 0, null, false, false}, + {3, 21.123f, 21.122f, 21.124f, false, false}, + + {5, null, 0, 2, true, true}, + {5, null, null, null, false, false}, + {5, null, 0, 100, false, false}, + {5, null, 0, 2, false, false}, + {5, null, -100, 0, false, false}, + {5, null, -2, 0, false, false}, + {5, null, 0, 100, true, true}, + {5, null, -100, 0, true, true}, + {5, null, -2, 0, true, true}, + {5, null, 0, null, false, false}, + {5, null, 0, 0, false, false}, + {5, 11.123d, 0, 13, true, true}, + {5, 12.123d, null, null, false, false}, + {5, 13.123d, 0, 100, false, false}, + {5, 14.123d, 0, 14, false, false}, + {5, 15.123d, -100, 0, false, false}, + {5, 16.123d, -16, 0, false, false}, + {5, 17.123d, 0, 100, true, true}, + {5, 18.123d, -100, 0, true, true}, + {5, 19.123d, -21, 0, true, true}, + {5, 20.123d, 0, null, false, false}, + {5, 21.123d, 21.122d, 21.124d, false, false}, + + }; + } + + @Test(dataProvider = "testRandomNumber") + void testRandomNumber(Number decimalPlaces, Number multipleOf, Number minimum, Number maximum, + boolean exclusiveMinimum, boolean exclusiveMaximum) { + + TestContext testContext = new TestContext(); + AdvancedRandomNumberFunction advancedRandomNumberFunction = new AdvancedRandomNumberFunction(); + try { + for (int i = 0; i < 1000; i++) { + + BigDecimal value = new BigDecimal(advancedRandomNumberFunction.execute( + List.of(toString(decimalPlaces), toString(minimum), toString(maximum), + toString(exclusiveMinimum), toString(exclusiveMaximum), toString(multipleOf)), testContext)); + + if (multipleOf != null) { + BigDecimal remainder = value.remainder(new BigDecimal(multipleOf.toString())); + + assertEquals( + remainder.compareTo(BigDecimal.ZERO), 0, + "Expected %s to be a multiple of %s! Remainder is %s".formatted( + value, multipleOf, + remainder)); + } + + if (maximum != null) { + if (exclusiveMaximum) { + assertTrue(value.doubleValue() < maximum.doubleValue(), + "Expected %s to be lower than %s!".formatted( + value, maximum)); + } else { + assertTrue(value.doubleValue() <= maximum.doubleValue(), + "Expected %s to be lower or equal than %s!".formatted( + value, maximum)); + } + } + + if (minimum != null) { + if (exclusiveMinimum) { + assertTrue(value.doubleValue() > minimum.doubleValue(), + "Expected %s to be larger than %s!".formatted( + value, minimum)); + } else { + assertTrue(value.doubleValue() >= minimum.doubleValue(), + "Expected %s to be larger or equal than %s!".formatted( + value, minimum)); + } + } + } + } catch (Exception e) { + Assert.fail("Creation of multiple float threw an exception: " + e.getMessage(), e); + } + } + + private String toString(Object obj) { + if (obj == null) { + return "null"; + } + return obj.toString(); + + } + + private T expectThrows(Class exceptionClass, Runnable runnable) { + try { + runnable.run(); + } catch (Throwable throwable) { + if (exceptionClass.isInstance(throwable)) { + return exceptionClass.cast(throwable); + } else { + throw new AssertionError("Unexpected exception type", throwable); + } + } + throw new AssertionError("Expected exception not thrown"); + } +} \ No newline at end of file diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java deleted file mode 100644 index 1452fb881b..0000000000 --- a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomDoubleFunctionTest.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.citrusframework.functions.core; - -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertTrue; - -import java.math.BigDecimal; -import java.util.List; -import org.citrusframework.context.TestContext; -import org.citrusframework.exceptions.InvalidFunctionUsageException; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; - -public class RandomDoubleFunctionTest { - - private AdvancedRandomNumberFunction function; - private TestContext context; - - @BeforeMethod - public void setUp() { - function = new AdvancedRandomNumberFunction(); - context = new TestContext(); - } - - @Test - public void testRandomNumberWithNullParameter() { - InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, - () -> function.execute(null, context)); - assertEquals(exception.getMessage(), - "Function parameters must not be null."); - } - - @Test - public void testRandomNumberWithDefaultValues() { - List params = List.of(); - String result = function.execute(params, context); - assertNotNull(result); - assertTrue(result.matches("-?\\d*")); - } - - @Test - public void testRandomNumberWithDecimalPlaces() { - List params = List.of("2"); - String result = function.execute(params, context); - assertNotNull(result); - assertTrue(result.matches("-?\\d*\\.\\d{2}"), "result does not match pattern: "+result); - } - - @Test - public void testRandomNumberWithinRange() { - List params = List.of("2", "10.5", "20.5"); - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue >= 10.5 && randomValue <= 20.5); - } - - @Test - public void testRandomNumberIncludesMin() { - List params = List.of("1", "10.5", "20.5"); - function = new AdvancedRandomNumberFunction() { - @Override - double getRandomValue(double minValue, BigDecimal range, double random) { - random = 0.0; - return super.getRandomValue(minValue, range, random); - } - }; - String result = function.execute(params, context); - assertEquals(result, "10.5"); - } - - @Test - public void testRandomNumberIncludesMax() { - List params = List.of("1", "10.5", "20.5"); - function = new AdvancedRandomNumberFunction() { - @Override - double getRandomValue(double minValue, BigDecimal range, double random) { - random = 1.0; - return super.getRandomValue(minValue, range, random); - } - }; - String result = function.execute(params, context); - assertEquals(result, "20.5"); - } - - @Test - public void testRandomNumberExcludeMin() { - List params = List.of("1", "10.5", "20.5", "true", "false"); - function = new AdvancedRandomNumberFunction() { - @Override - double getRandomValue(double minValue, BigDecimal range, double random) { - random = 0.0; - return super.getRandomValue(minValue, range, random); - } - }; - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue > 10.5 && randomValue <= 20.5); - } - - @Test - public void testRandomNumberExcludeMax() { - List params = List.of("2", "10.5", "20.5", "false", "true"); - function = new AdvancedRandomNumberFunction() { - @Override - double getRandomValue(double minValue, BigDecimal range, double random) { - random = 1.0; - return super.getRandomValue(minValue, range, random); - } - }; - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue >= 10.5 && randomValue < 20.5); - } - - @Test - public void testRandomInteger32EdgeCase() { - List params = List.of("0", "-2147483648", "2147483647", "false", "false"); - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue >= -Integer.MAX_VALUE && randomValue < Integer.MAX_VALUE); - } - - @Test - public void testRandomInteger32MinEqualsMaxEdgeCase() { - List params = List.of("0", "3", "3", "false", "false"); - for (int i =0;i<100;i++) { - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertEquals(randomValue, 3); - } - } - - - - // randomDouble('0','3','3','true','true') - // randomDouble('0','3','3','true','true') - - @Test - public void testRandomDouble32MinEqualsMaxEdgeCase() { - List params = List.of("2", "3.0", "3.0", "false", "false"); - for (int i =0;i<100;i++) { - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertEquals(randomValue, 3); - } - } - - @Test - public void testRandomInteger64EdgeCase() { - List params = List.of("0", "-9223372036854775808", "9223372036854775807", "false", "false"); - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue >=-Long.MAX_VALUE && randomValue < Long.MAX_VALUE); - } - - @Test - public void testRandomNumberFloatEdgeCase() { - List params = List.of("0", "-3.4028235E38", "3.4028235E38", "false", "false"); - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue >= -Float.MAX_VALUE && randomValue < Float.MAX_VALUE); - } - - @Test - public void testRandomNumberDoubleEdgeCase() { - List params = List.of("0", "-1.7976931348623157E308", "1.7976931348623157E308", "false", "false"); - String result = function.execute(params, context); - assertNotNull(result); - double randomValue = Double.parseDouble(result); - assertTrue(randomValue >= -Double.MAX_VALUE && randomValue < Double.MAX_VALUE); - } - - @Test - public void testInvalidDecimalPlaces() { - List params = List.of("-1"); // invalid decimalPlaces - InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); - assertEquals(exception.getMessage(), "Invalid parameter definition. Decimal places must be a non-negative integer value."); - } - - @Test - public void testInvalidRange() { - List params = List.of("2", "20.5", "10.5"); // invalid range - InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); - assertEquals(exception.getMessage(), "Invalid parameter definition. Min value must be less than max value."); - } - - @Test - public void testInvalidDecimalPlacesFormat() { - List params = List.of("xxx"); // invalid decimalPlaces - InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); - assertEquals(exception.getMessage(), "Invalid parameter at index 1. xxx must be parsable to Integer."); - } - - @Test - public void testInvalidMinValueFormat() { - List params = List.of("1","xxx"); // invalid min value - InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, () -> function.execute(params, context)); - assertEquals(exception.getMessage(), "Invalid parameter at index 2. xxx must be parsable to Double."); - } - - @Test - public void testInvalidMaxValueFormat() { - List params = List.of("1", "1.1", "xxx"); // invalid max value - InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class, - () -> function.execute(params, context)); - assertEquals(exception.getMessage(), - "Invalid parameter at index 3. xxx must be parsable to Double."); - } - - private T expectThrows(Class exceptionClass, Runnable runnable) { - try { - runnable.run(); - } catch (Throwable throwable) { - if (exceptionClass.isInstance(throwable)) { - return exceptionClass.cast(throwable); - } else { - throw new AssertionError("Unexpected exception type", throwable); - } - } - throw new AssertionError("Expected exception not thrown"); - } -} \ No newline at end of file diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java index 0d06a912b1..a70dd7c8cf 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java @@ -17,8 +17,10 @@ package org.citrusframework.functions.core; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.citrusframework.UnitTestSupport; import org.citrusframework.exceptions.InvalidFunctionUsageException; import org.testng.Assert; @@ -31,7 +33,8 @@ * @author Christoph Deppisch */ public class RandomStringFunctionTest extends UnitTestSupport { - private RandomStringFunction function = new RandomStringFunction(); + + private final RandomStringFunction function = new RandomStringFunction(); @Test public void testFunction() { @@ -113,8 +116,31 @@ public void testTooManyParameters() { params.add("3"); params.add("UPPERCASE"); params.add("true"); - params.add("too much"); + params.add("0"); + params.add("too many"); function.execute(params, context); } -} + + @Test + public void testRandomSize() { + List params; + params = new ArrayList<>(); + params.add("10"); + params.add("UPPERCASE"); + params.add("true"); + params.add("8"); + + Set sizes = new HashSet<>(); + + for (int i = 0; i < 1000; i++) { + String text = function.execute(params, context); + sizes.add(text.length()); + } + + Assert.assertTrue(sizes.contains(8)); + Assert.assertTrue(sizes.contains(9)); + Assert.assertTrue(sizes.contains(10)); + + } +} \ No newline at end of file