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 e2d3d1feeb..74deef213f 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,72 +17,125 @@ package org.citrusframework.openapi; import io.apicurio.datamodels.openapi.models.OasSchema; -import jakarta.annotation.Nullable; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; 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 org.citrusframework.openapi.util.OpenApiUtils; +import org.citrusframework.openapi.util.RandomModelBuilder; + +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 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; + /** - * 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. - * + * Generates proper payloads and validation 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; } @@ -90,20 +143,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); @@ -112,31 +157,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("\"\""); @@ -145,69 +203,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(); } /** @@ -222,182 +244,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/OpenApiClientRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java index 07ab6bd1c6..616e5b119b 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,17 +39,18 @@ 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; - /** * @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. @@ -57,14 +63,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; } @@ -117,7 +132,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) @@ -171,4 +186,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 de3998e10e..3bc4b9d1c4 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,17 +42,18 @@ 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; - /** * @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. @@ -62,15 +68,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; } @@ -88,12 +103,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( @@ -108,7 +121,6 @@ public static void fillMessageFromResponse(OpenApiSpecification openApiSpecifica } } ); - } private static void fillRequiredHeaders( @@ -118,7 +130,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, @@ -145,6 +157,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) { @@ -169,7 +183,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); @@ -184,5 +198,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 9d93fd2326..bfb8c4ea5c 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,32 +40,25 @@ 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; - /** * @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. @@ -66,15 +72,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; } @@ -142,7 +156,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))); } @@ -161,7 +175,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)); @@ -188,7 +202,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, @@ -209,7 +223,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 2c673b711a..d5914d336e 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,30 +55,18 @@ 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; - /** * @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. @@ -75,16 +81,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 854f8f743d..cfbffdf139 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,12 +52,14 @@ 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; 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, @@ -76,7 +77,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); } /** @@ -85,7 +86,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); } /** @@ -95,7 +96,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; } @@ -290,7 +291,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 @@ -463,7 +464,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. @@ -491,10 +492,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_); } } @@ -504,8 +505,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 42aadd18d8..94786d08c2 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 @@ -31,6 +31,8 @@ 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; @@ -59,25 +61,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) @@ -94,7 +110,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 { @@ -114,12 +132,6 @@ public void shouldProperlyExecuteGetAndAddPetFromRepository() { shouldExecuteGetAndAddPet(openapi(petstoreSpec)); } - @CitrusTest - @Test - public void shouldFailOnMissingNameInResponse() { - shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, false); - } - @CitrusTest @Test public void shouldFailOnMissingNameInRequest() { @@ -203,4 +215,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 d29a11f75a..82d48fa427 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 @@ -18,6 +18,7 @@ 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; @@ -67,11 +68,6 @@ public class OpenApiServerIT extends TestNGCitrusSpringSupport { @CitrusTest public void shouldExecuteGetPetById() { - shouldExecuteGetPetById(openapi(petstoreSpec)); - } - - - private void shouldExecuteGetPetById(OpenApiActionBuilder openapi) { variable("petId", "1001"); when(http() @@ -82,11 +78,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)); @@ -110,6 +106,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); @@ -167,7 +253,7 @@ public void shouldFailOnWrongQueryIdTypeWithOasDisabled() { @CitrusTest public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { - variable("petId", "xxx"); + variable("petId", -1); when(http() .client(httpClient) @@ -181,7 +267,7 @@ public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { OpenApiServerRequestActionBuilder addPetBuilder = openapi(petstoreSpec) .server(httpServer) .receive("addPet") - .disableOasValidation(true); + .disableOasValidation(false); try { when(addPetBuilder); @@ -220,4 +306,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 30ad30e4eb..ca2d7e8732 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 @@ -249,4 +249,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 65ffda4f1c..dfdab6d870 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 dcd06c91bb..eb70e1e543 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; @@ -63,7 +65,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 3fbc64c812..3d6be1f9b3 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 @@ -61,4 +61,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/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 47b42c3195..dc6269b8f4 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; @@ -38,8 +47,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); } @@ -65,4 +74,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 92edb71fe9..9bce6d1042 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; @@ -46,16 +55,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 @@ -76,20 +85,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 @@ -104,19 +113,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))); } @@ -136,4 +145,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("")); + } + }