diff --git a/connectors/citrus-openapi/pom.xml b/connectors/citrus-openapi/pom.xml index bf85267d78..fe5548affa 100644 --- a/connectors/citrus-openapi/pom.xml +++ b/connectors/citrus-openapi/pom.xml @@ -72,6 +72,12 @@ ${project.version} test + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.17.0 + compile + diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java new file mode 100644 index 0000000000..75b62e59c7 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java @@ -0,0 +1,66 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.ArrayList; +import java.util.List; +import org.citrusframework.repository.BaseRepository; +import org.citrusframework.spi.Resource; + +/** + * OpenApi repository holding a set of {@link OpenApiSpecification} known in the test scope. + * @since 4.4.0 + */ +public class OpenApiRepository extends BaseRepository { + + private static final String DEFAULT_NAME = "openApiSchemaRepository"; + + /** List of schema resources */ + private final List openApiSpecifications = new ArrayList<>(); + + + /** An optional context path, used for each api, without taking into account any {@link OpenApiSpecification} specific context path. */ + private String rootContextPath; + + public OpenApiRepository() { + super(DEFAULT_NAME); + } + + public String getRootContextPath() { + return rootContextPath; + } + + public void setRootContextPath(String rootContextPath) { + this.rootContextPath = rootContextPath; + } + + @Override + public void addRepository(Resource openApiResource) { + + OpenApiSpecification openApiSpecification = OpenApiSpecification.from(openApiResource); + openApiSpecification.setRootContextPath(rootContextPath); + + this.openApiSpecifications.add(openApiSpecification); + + OpenApiSpecificationProcessor.lookup().values().forEach(processor -> processor.process(openApiSpecification)); + } + + public List getOpenApiSpecifications() { + return openApiSpecifications; + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java index 00c5a1c382..5c28f5e67a 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java @@ -34,12 +34,21 @@ */ public class OpenApiSpecification { + public static final String HTTPS = "https"; + public static final String HTTP = "http"; /** URL to load the OpenAPI specification */ private String specUrl; private String httpClient; private String requestUrl; + /** + * The optional root context path to which the OpenAPI is hooked. + * This path is prepended to the base path specified in the OpenAPI configuration. + * If no root context path is specified, only the base path and additional segments are used. + */ + private String rootContextPath; + private OasDocument openApiDoc; private boolean generateOptionalFields = true; @@ -56,7 +65,7 @@ public static OpenApiSpecification from(String specUrl) { public static OpenApiSpecification from(URL specUrl) { OpenApiSpecification specification = new OpenApiSpecification(); OasDocument openApiDoc; - if (specUrl.getProtocol().startsWith("https")) { + if (specUrl.getProtocol().startsWith(HTTPS)) { openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specUrl); } else { openApiDoc = OpenApiResourceLoader.fromWebResource(specUrl); @@ -76,11 +85,11 @@ public static OpenApiSpecification from(Resource resource) { specification.setOpenApiDoc(openApiDoc); String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) - .orElse(Collections.singletonList("http")) + .orElse(Collections.singletonList(HTTP)) .stream() - .filter(s -> s.equals("http") || s.equals("https")) + .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) .findFirst() - .orElse("http"); + .orElse(HTTP); specification.setSpecUrl(resource.getLocation()); specification.setRequestUrl(String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), OasModelHelper.getBasePath(openApiDoc))); @@ -102,17 +111,17 @@ public OasDocument getOpenApiDoc(TestContext context) { resolvedSpecUrl = requestUrl.endsWith("/") ? requestUrl + resolvedSpecUrl.substring(1) : requestUrl + resolvedSpecUrl; } else if (httpClient != null && context.getReferenceResolver().isResolvable(httpClient, HttpClient.class)) { String baseUrl = context.getReferenceResolver().resolve(httpClient, HttpClient.class).getEndpointConfiguration().getRequestUrl(); - resolvedSpecUrl = baseUrl.endsWith("/") ? baseUrl + resolvedSpecUrl.substring(1) : baseUrl + resolvedSpecUrl;; + resolvedSpecUrl = baseUrl.endsWith("/") ? baseUrl + resolvedSpecUrl.substring(1) : baseUrl + resolvedSpecUrl; } else { throw new CitrusRuntimeException(("Failed to resolve OpenAPI spec URL from relative path %s - " + "make sure to provide a proper base URL when using relative paths").formatted(resolvedSpecUrl)); } } - if (resolvedSpecUrl.startsWith("http")) { + if (resolvedSpecUrl.startsWith(HTTP)) { try { URL specWebResource = new URL(resolvedSpecUrl); - if (resolvedSpecUrl.startsWith("https")) { + if (resolvedSpecUrl.startsWith(HTTPS)) { openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specWebResource); } else { openApiDoc = OpenApiResourceLoader.fromWebResource(specWebResource); @@ -129,11 +138,11 @@ public OasDocument getOpenApiDoc(TestContext context) { if (requestUrl == null) { String schemeToUse = Optional.ofNullable(OasModelHelper.getSchemes(openApiDoc)) - .orElse(Collections.singletonList("http")) + .orElse(Collections.singletonList(HTTP)) .stream() - .filter(s -> s.equals("http") || s.equals("https")) + .filter(s -> s.equals(HTTP) || s.equals(HTTPS)) .findFirst() - .orElse("http"); + .orElse(HTTP); setRequestUrl(String.format("%s://%s%s", schemeToUse, OasModelHelper.getHost(openApiDoc), OasModelHelper.getBasePath(openApiDoc))); } @@ -190,4 +199,14 @@ public boolean isValidateOptionalFields() { public void setValidateOptionalFields(boolean validateOptionalFields) { this.validateOptionalFields = validateOptionalFields; } + + public String getRootContextPath() { + return rootContextPath; + } + + public void setRootContextPath(String rootContextPath) { + this.rootContextPath = rootContextPath; + } + + } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java new file mode 100644 index 0000000000..b7ca0b5bdc --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java @@ -0,0 +1,59 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.Map; +import org.citrusframework.spi.ResourcePathTypeResolver; +import org.citrusframework.spi.TypeResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Interface for processing OpenAPI specifications. + *

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

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

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

+ * + * @param responses the {@link OasResponses} instance containing the responses to be resolved. + * @param responseResolver a {@link Function} that takes a reference string and returns the corresponding {@link OasResponse}. + * @return a {@link List} of {@link OasResponse} instances, where all references have been resolved. + */ + public static List resolveResponses(OasResponses responses, Function responseResolver) { + + List responseList = new ArrayList<>(); + for (OasResponse response : responses.getResponses()) { + if (response.$ref != null) { + OasResponse resolved = responseResolver.apply(getReferenceName(response.$ref)); + if (resolved != null) { + responseList.add(resolved); + } + } else { + responseList.add(response); + } + } + + return responseList; + } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java index 36d9596fd4..9ef8d50538 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java @@ -16,21 +16,25 @@ package org.citrusframework.openapi.model.v2; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - import io.apicurio.datamodels.openapi.models.OasHeader; import io.apicurio.datamodels.openapi.models.OasParameter; +import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; import io.apicurio.datamodels.openapi.v2.models.Oas20Document; import io.apicurio.datamodels.openapi.v2.models.Oas20Header; import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; import io.apicurio.datamodels.openapi.v2.models.Oas20SchemaDefinition; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.citrusframework.openapi.model.OasModelHelper; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -89,14 +93,60 @@ public static Optional getRequestContentType(Oas20Operation operation) { return Optional.empty(); } - public static Optional getResponseContentType(Oas20Document openApiDoc, Oas20Operation operation) { + public static Collection getResponseTypes(Oas20Operation operation, Oas20Response response) { + if (operation == null) { + return Collections.emptyList(); + } + return operation.produces; + } + + /** + * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, + * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. + * + * @param openApiDoc + * @param operation + * @return + */ + public static Optional getResponseContentTypeForRandomGeneration(Oas20Document openApiDoc, Oas20Operation operation) { if (operation.produces != null) { - return Optional.of(operation.produces.get(0)); + for (String mediaType : operation.produces) { + if (MediaType.APPLICATION_JSON_VALUE.equals(mediaType)) { + return Optional.of(mediaType); + } + } } return Optional.empty(); } + public static Optional getResponseForRandomGeneration(Oas20Document openApiDoc, Oas20Operation operation) { + + if (operation.responses == null) { + return Optional.empty(); + } + + List responses = OasModelHelper.resolveResponses(operation.responses, + responseRef -> openApiDoc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))); + + // Pick the response object related to the first 2xx return code found + Optional response = responses.stream() + .filter(Oas20Response.class::isInstance) + .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) + .map(OasResponse.class::cast) + .filter(res -> OasModelHelper.getSchema(res).isPresent()) + .findFirst(); + + if (response.isEmpty()) { + // TODO: Although the Swagger specification states that at least one successful response SHOULD be specified in the responses, + // the Petstore API does not. It only specifies error responses. As a result, we currently only return a successful response if one is found. + // If no successful response is specified, we return an empty response instead, to be backwards compatible. + response = Optional.empty(); + } + + return response; + } + public static Map getHeaders(Oas20Response response) { if (response.headers == null) { return Collections.emptyMap(); @@ -106,6 +156,13 @@ public static Map getHeaders(Oas20Response response) { .collect(Collectors.toMap(OasHeader::getName, Oas20ModelHelper::getHeaderSchema)); } + /** + * If the header already contains a schema (and it is an instance of {@link Oas20Header}), this schema is returned. + * Otherwise, a new {@link Oas20Header} is created based on the properties of the parameter and returned. + * + * @param header the {@link Oas20Header} from which to extract or create the schema + * @return an {@link Optional} containing the extracted or newly created {@link OasSchema} + */ private static OasSchema getHeaderSchema(Oas20Header header) { Oas20Schema schema = new Oas20Schema(); schema.title = header.getName(); @@ -132,4 +189,43 @@ private static OasSchema getHeaderSchema(Oas20Header header) { schema.exclusiveMinimum = header.exclusiveMinimum; return schema; } + + /** + * If the parameter already contains a schema (and it is an instance of {@link Oas20Schema}), this schema is returned. + * Otherwise, a new {@link Oas20Schema} is created based on the properties of the parameter and returned. + * + * @param parameter the {@link Oas20Parameter} from which to extract or create the schema + * @return an {@link Optional} containing the extracted or newly created {@link OasSchema} + */ + public static Optional getParameterSchema(Oas20Parameter parameter) { + if (parameter.schema instanceof Oas20Schema oasSchema) { + return Optional.of(oasSchema); + } + + Oas20Schema schema = new Oas20Schema(); + schema.title = parameter.getName(); + schema.type = parameter.type; + schema.format = parameter.format; + schema.items = parameter.items; + schema.multipleOf = parameter.multipleOf; + + schema.default_ = parameter.default_; + schema.enum_ = parameter.enum_; + + schema.pattern = parameter.pattern; + schema.description = parameter.description; + schema.uniqueItems = parameter.uniqueItems; + + schema.maximum = parameter.maximum; + schema.maxItems = parameter.maxItems; + schema.maxLength = parameter.maxLength; + schema.exclusiveMaximum = parameter.exclusiveMaximum; + + schema.minimum = parameter.minimum; + schema.minItems = parameter.minItems; + schema.minLength = parameter.minLength; + schema.exclusiveMinimum = parameter.exclusiveMinimum; + + return Optional.of(schema); + } } diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java index 39bdd2486e..c8c72f5587 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java @@ -16,17 +16,6 @@ package org.citrusframework.openapi.model.v3; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - import io.apicurio.datamodels.core.models.common.Server; import io.apicurio.datamodels.core.models.common.ServerVariable; import io.apicurio.datamodels.openapi.models.OasResponse; @@ -34,11 +23,24 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType; import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; import org.citrusframework.openapi.model.OasModelHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; /** * @author Christoph Deppisch @@ -47,6 +49,7 @@ public final class Oas30ModelHelper { /** Logger */ private static final Logger LOG = LoggerFactory.getLogger(Oas30ModelHelper.class); + public static final String NO_URL_ERROR_MESSAGE = "Unable to determine base path from server URL: %s"; private Oas30ModelHelper() { // utility class @@ -62,7 +65,7 @@ public static String getHost(Oas30Document openApiDoc) { try { return new URL(serverUrl).getHost(); } catch (MalformedURLException e) { - throw new IllegalStateException(String.format("Unable to determine base path from server URL: %s", serverUrl)); + throw new IllegalStateException(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); } } @@ -80,12 +83,12 @@ public static List getSchemes(Oas30Document openApiDoc) { try { return new URL(serverUrl).getProtocol(); } catch (MalformedURLException e) { - LOG.warn(String.format("Unable to determine base path from server URL: %s", serverUrl)); + LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); return null; } }) .filter(Objects::nonNull) - .collect(Collectors.toList()); + .toList(); } public static String getBasePath(Oas30Document openApiDoc) { @@ -101,7 +104,7 @@ public static String getBasePath(Oas30Document openApiDoc) { try { basePath = new URL(serverUrl).getPath(); } catch (MalformedURLException e) { - throw new IllegalStateException(String.format("Unable to determine base path from server URL: %s", serverUrl)); + throw new IllegalStateException(String.format(NO_URL_ERROR_MESSAGE, serverUrl)); } } else { basePath = serverUrl; @@ -117,7 +120,7 @@ public static Map getSchemaDefinitions(Oas30Document openApiD return openApiDoc.components.schemas.entrySet() .stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> (OasSchema) entry.getValue())); + .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue)); } public static Optional getSchema(Oas30Response response) { @@ -172,44 +175,69 @@ public static Optional getRequestContentType(Oas30Operation operation) { .findFirst(); } - public static Optional getResponseContentType(Oas30Document openApiDoc, Oas30Operation operation) { - if (operation.responses == null) { - return Optional.empty(); + public static Collection getResponseTypes(Oas30Operation operation, Oas30Response response) { + if (operation == null) { + return Collections.emptySet(); } + return response.content != null ? response.content.keySet() : Collections.emptyList(); + } - List responses = new ArrayList<>(); + /** + * Returns the response content for random response generation. Note that this implementation currently only returns {@link MediaType#APPLICATION_JSON_VALUE}, + * if this type exists. Otherwise, it will return an empty Optional. The reason for this is, that we cannot safely guess the type other than for JSON. + * + * @param openApiDoc + * @param operation + * @return + */ + public static Optional getResponseContentTypeForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { + Optional responseForRandomGeneration = getResponseForRandomGeneration( + openApiDoc, operation); + return responseForRandomGeneration.map( + Oas30Response.class::cast).flatMap(res -> res.content.entrySet() + .stream() + .filter(entry -> MediaType.APPLICATION_JSON_VALUE.equals(entry.getKey())) + .filter(entry -> entry.getValue().schema != null) + .map(Map.Entry::getKey) + .findFirst()); + } - for (OasResponse response : operation.responses.getResponses()) { - if (response.$ref != null) { - responses.add(openApiDoc.components.responses.get(OasModelHelper.getReferenceName(response.$ref))); - } else { - responses.add(response); - } + public static Optional getResponseForRandomGeneration(Oas30Document openApiDoc, Oas30Operation operation) { + if (operation.responses == null) { + return Optional.empty(); } + List responses = OasModelHelper.resolveResponses(operation.responses, + responseRef -> openApiDoc.components.responses.get(OasModelHelper.getReferenceName(responseRef))); + // Pick the response object related to the first 2xx return code found - Optional response = responses.stream() - .filter(Oas30Response.class::isInstance) - .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) - .map(Oas30Response.class::cast) - .filter(res -> Oas30ModelHelper.getSchema(res).isPresent()) - .findFirst(); + Optional response = responses.stream() + .filter(Oas30Response.class::isInstance) + .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2")) + .map(OasResponse.class::cast) + .filter(res -> OasModelHelper.getSchema(res).isPresent()) + .findFirst(); // No 2xx response given so pick the first one no matter what status code - if (!response.isPresent()) { + if (response.isEmpty()) { + // TODO: This behavior differs from OAS2 and is very likely a bug because it may result in returning error messages. + // According to the specification, there MUST be at least one response, which SHOULD be a successful response. + // If the response is NOT A SUCCESSFUL one, we encounter an error case, which is likely not the intended behavior. + // The specification likely does not intend to define operations that always fail. On the other hand, it is not + // against the spec to NOT document an OK response that is empty. + // For testing purposes, note that the difference between OAS2 and OAS3 is evident in the Petstore API. + // The Petstore API specifies successful response codes for OAS3 but lacks these definitions for OAS2. + // Therefore, while tests pass for OAS3, they fail for OAS2. + // I would suggest to return an empty response in case we fail to resolve a good response, as in Oas2. + // In case of absence of a response an OK response will be sent as default. response = responses.stream() - .filter(Oas30Response.class::isInstance) - .map(Oas30Response.class::cast) - .filter(res -> Oas30ModelHelper.getSchema(res).isPresent()) - .findFirst(); + .filter(Oas30Response.class::isInstance) + .map(OasResponse.class::cast) + .filter(res -> OasModelHelper.getSchema(res).isPresent()) + .findFirst(); } - return response.flatMap(res -> res.content.entrySet() - .stream() - .filter(entry -> entry.getValue().schema != null) - .map(Map.Entry::getKey) - .findFirst()); - + return response; } public static Map getRequiredHeaders(Oas30Response response) { @@ -254,4 +282,8 @@ private static String resolveUrl(Server server) { return url; } + + public static Optional getParameterSchema(Oas30Parameter parameter) { + return Optional.ofNullable((OasSchema) parameter.schema); + } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java new file mode 100644 index 0000000000..3c449ff5fe --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java @@ -0,0 +1,50 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import java.util.List; +import org.testng.annotations.Test; + +@Test +public class OpenApiRepositoryTest { + + public static final String ROOT = "/root"; + + public void initializeOpenApiRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setRootContextPath(ROOT); + openApiRepository.setLocations(List.of("org/citrusframework/openapi/petstore/petstore**.json")); + openApiRepository.initialize(); + + List openApiSpecifications = openApiRepository.getOpenApiSpecifications(); + + assertEquals(openApiRepository.getRootContextPath(), ROOT); + assertNotNull(openApiSpecifications); + assertEquals(openApiSpecifications.size(),3); + + assertEquals(openApiSpecifications.get(0).getRootContextPath(), ROOT); + assertEquals(openApiSpecifications.get(1).getRootContextPath(), ROOT); + + assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(0))); + assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(1))); + assertTrue(SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(2))); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java new file mode 100644 index 0000000000..c39605e35e --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java @@ -0,0 +1,30 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.ArrayList; +import java.util.List; + +public class SampleOpenApiProcessor implements OpenApiSpecificationProcessor { + + public static List processedSpecifications = new ArrayList<>(); + + @Override + public void process(OpenApiSpecification openApiSpecification) { + processedSpecifications.add(openApiSpecification); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java new file mode 100644 index 0000000000..742641ad6c --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java @@ -0,0 +1,119 @@ +package org.citrusframework.openapi.model.v2; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasResponse; +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Document; +import io.apicurio.datamodels.openapi.v2.models.Oas20Items; +import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; +import io.apicurio.datamodels.openapi.v2.models.Oas20Response; +import io.apicurio.datamodels.openapi.v2.models.Oas20Responses; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import java.util.List; +import java.util.Optional; +import org.testng.annotations.Test; + +public class Oas20ModelHelperTest { + + @Test + public void shouldFindRandomResponse() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse = new Oas20Response("403"); + nokResponse.schema = new Oas20Schema(); + + Oas20Response okResponse = new Oas20Response("200"); + okResponse.schema = new Oas20Schema(); + + operation.responses = new Oas20Responses(); + operation.responses.addResponse("403", nokResponse); + operation.responses.addResponse("200", okResponse); + + Optional responseForRandomGeneration = Oas20ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(okResponse, responseForRandomGeneration.get()); + } + + @Test + public void shouldNotFindAnyResponse() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse403 = new Oas20Response("403"); + Oas20Response nokResponse407 = new Oas20Response("407"); + + operation.responses = new Oas20Responses(); + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = Oas20ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isEmpty()); + } + + @Test + public void shouldFindParameterSchema() { + Oas20Parameter parameter = new Oas20Parameter(); + parameter.schema = new Oas20Schema(); + + Optional parameterSchema = Oas20ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isPresent()); + assertEquals(parameter.schema, parameterSchema.get()); + } + + @Test + public void shouldFindSchemaFromParameter() { + Oas20Parameter parameter = new Oas20Parameter("testParameter"); + parameter.type = "string"; + parameter.format = "date-time"; + parameter.items = new Oas20Items(); + parameter.multipleOf = 2; + parameter.default_ = "defaultValue"; + parameter.enum_ = List.of("value1", "value2"); + parameter.pattern = "pattern"; + parameter.description = "description"; + parameter.uniqueItems = true; + parameter.maximum = 100.0; + parameter.maxItems = 10; + parameter.maxLength = 20; + parameter.exclusiveMaximum = true; + parameter.minimum = 0.0; + parameter.minItems = 1; + parameter.minLength = 5; + parameter.exclusiveMinimum = false; + + Optional schemaOptional = Oas20ModelHelper.getParameterSchema(parameter); + assertTrue(schemaOptional.isPresent()); + + OasSchema parameterSchema = schemaOptional.get(); + assertEquals(parameterSchema.title, "testParameter"); + assertEquals(parameterSchema.type, "string"); + assertEquals(parameterSchema.format, "date-time"); + assertEquals(parameter.items, parameterSchema.items); + assertEquals(parameter.multipleOf, parameterSchema.multipleOf); + assertEquals(parameter.default_, parameterSchema.default_); + assertEquals(parameter.enum_, parameterSchema.enum_); + assertEquals(parameter.pattern, parameterSchema.pattern); + assertEquals(parameter.description, parameterSchema.description); + assertEquals(parameter.uniqueItems, parameterSchema.uniqueItems); + assertEquals(parameter.maximum, parameterSchema.maximum); + assertEquals(parameter.maxItems, parameterSchema.maxItems); + assertEquals(parameter.maxLength, parameterSchema.maxLength); + assertEquals(parameter.exclusiveMaximum, parameterSchema.exclusiveMaximum); + assertEquals(parameter.minimum, parameterSchema.minimum); + assertEquals(parameter.minItems, parameterSchema.minItems); + assertEquals(parameter.minLength, parameterSchema.minLength); + assertEquals(parameter.exclusiveMinimum, parameterSchema.exclusiveMinimum); + + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java index 38d8b13fde..8735ecd083 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java @@ -1,13 +1,24 @@ package org.citrusframework.openapi.model.v3; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30Header; +import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import io.apicurio.datamodels.openapi.v3.models.Oas30Responses; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; -import org.testng.Assert; -import org.testng.annotations.Test; - +import java.util.Collection; import java.util.Map; +import java.util.Optional; +import org.springframework.http.MediaType; +import org.testng.annotations.Test; public class Oas30ModelHelperTest { @@ -15,40 +26,135 @@ public class Oas30ModelHelperTest { public void shouldNotFindRequiredHeadersWithoutRequiredAttribute() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = null; // explicitely assigned because this is test case + header.required = null; // explicitly assigned because this is test case var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 0); + assertEquals(result.size(), 0); } @Test public void shouldFindRequiredHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.TRUE; // explicitely assigned because this is test case + header.required = Boolean.TRUE; // explicitly assigned because this is test case var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 1); - Assert.assertSame(result.get(header.getName()), header.schema); + assertEquals(result.size(), 1); + assertSame(result.get(header.getName()), header.schema); } @Test public void shouldNotFindOptionalHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.FALSE; // explicitely assigned because this is test case + header.required = Boolean.FALSE; // explicitly assigned because this is test case var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 0); + assertEquals(result.size(), 0); + } + + @Test + public void shouldFindAllRequestTypesForOperation() { + Oas30Operation operation = new Oas30Operation("GET"); + operation.responses = new Oas30Responses(); + + Oas30Response response = new Oas30Response("200"); + response.content = Map.of(MediaType.APPLICATION_JSON_VALUE, + new Oas30MediaType(MediaType.APPLICATION_JSON_VALUE), + MediaType.APPLICATION_XML_VALUE, new Oas30MediaType(MediaType.APPLICATION_XML_VALUE)); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("200", response); + + Collection responseTypes = Oas30ModelHelper.getResponseTypes(operation, response); + + assertTrue(responseTypes.contains(MediaType.APPLICATION_JSON_VALUE)); + assertTrue(responseTypes.contains(MediaType.APPLICATION_XML_VALUE)); + + } + + @Test + public void shouldFindRandomResponse() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response okResponse = new Oas30Response("200"); + Oas30MediaType jsonMediaType = new Oas30MediaType(MediaType.APPLICATION_JSON_VALUE); + jsonMediaType.schema = new Oas30Schema(); + + Oas30MediaType xmlMediaType = new Oas30MediaType(MediaType.APPLICATION_XML_VALUE); + xmlMediaType.schema = new Oas30Schema(); + + okResponse.content = Map.of(MediaType.APPLICATION_JSON_VALUE, jsonMediaType, + MediaType.APPLICATION_XML_VALUE, xmlMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("403", nokResponse); + operation.responses.addResponse("200", okResponse); + + Optional responseForRandomGeneration = Oas30ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(okResponse, responseForRandomGeneration.get()); + } + + @Test + public void shouldFindAnyResponse() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse403 = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse403.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response nokResponse407 = new Oas30Response("407"); + nokResponse407.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = Oas30ModelHelper.getResponseForRandomGeneration( + document, operation); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(nokResponse403, responseForRandomGeneration.get()); + } + + @Test + public void shouldFindParameterSchema() { + Oas30Parameter parameter = new Oas30Parameter(); + parameter.schema = new Oas30Schema(); + + Optional parameterSchema = Oas30ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isPresent()); + assertEquals(parameter.schema, parameterSchema.get()); + } + + @Test + public void shouldNotFindParameterSchema() { + Oas30Parameter parameter = new Oas30Parameter(); + + Optional parameterSchema = Oas30ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isEmpty()); } } \ No newline at end of file diff --git a/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor b/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor new file mode 100644 index 0000000000..1092e9da72 --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor @@ -0,0 +1,2 @@ +name=sampleOpenApiProcessor +type=org.citrusframework.openapi.SampleOpenApiProcessor diff --git a/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java b/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java new file mode 100644 index 0000000000..eae38431bd --- /dev/null +++ b/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java @@ -0,0 +1,107 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.repository; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.citrusframework.common.InitializingPhase; +import org.citrusframework.common.Named; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.spi.ClasspathResourceResolver; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources; +import org.citrusframework.util.FileUtils; +import org.citrusframework.util.StringUtils; + +/** + * Base class for repositories providing common functionality for initializing and managing resources. + * Implementations must provide the logic for loading and adding resources to the repository. + */ +public abstract class BaseRepository implements Named, InitializingPhase { + + private String name; + + /** List of location patterns that will be translated to schema resources */ + private List locations = new ArrayList<>(); + + protected BaseRepository(String name) { + this.name = name; + } + + @Override + public void initialize() { + try { + ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver(); + for (String location : locations) { + Resource found = Resources.create(location); + if (found.exists()) { + addRepository(found); + } else { + Set findings; + if (StringUtils.hasText(FileUtils.getFileExtension(location))) { + String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*"); + String basePath = FileUtils.getBasePath(location); + findings = resourceResolver.getResources(basePath, fileNamePattern); + } else { + findings = resourceResolver.getResources(location); + } + + for (Path resource : findings) { + addRepository(Resources.fromClasspath(resource.toString())); + } + } + } + } catch (IOException e) { + throw new CitrusRuntimeException("Failed to initialize repository", e); + } + } + + protected abstract void addRepository(Resource resource); + + @Override + public void setName(String name) { + this.name = name; + } + + /** + * Gets the name. + * @return the name to get. + */ + public String getName() { + return name; + } + + /** + * Gets the locations. + * @return the locations to get. + */ + public List getLocations() { + return locations; + } + + /** + * Sets the locations. + * @param locations the locations to set + */ + public void setLocations(List locations) { + this.locations = locations; + } + +} diff --git a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java index b1f208a21b..d99932ea55 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java +++ b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java @@ -42,4 +42,25 @@ public static boolean hasText(String str) { public static boolean isEmpty(String str) { return str == null || str.isEmpty(); } + + public static String appendSegmentToPath(String path, String segment) { + + if (path == null) { + return segment; + } + + if (segment == null) { + return path; + } + + if (!path.endsWith("/")) { + path = path +"/"; + } + + if (segment.startsWith("/")) { + segment = segment.substring(1); + } + + return path+segment; + } } diff --git a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java new file mode 100644 index 0000000000..e1b1bab770 --- /dev/null +++ b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java @@ -0,0 +1,36 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.util; + +import org.testng.Assert; +import org.testng.annotations.Test; + +public class StringUtilsTest { + + @Test + public void appendSegmentToPath() { + Assert.assertEquals(StringUtils.appendSegmentToPath("s1","s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("s1/","s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("s1/","/s2"), "s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1","/s2"), "/s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/","/s2"), "/s1/s2"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/","/s2/"), "/s1/s2/"); + Assert.assertEquals(StringUtils.appendSegmentToPath("/s1/",null), "/s1/"); + Assert.assertEquals(StringUtils.appendSegmentToPath(null,"/s2/"), "/s2/"); + Assert.assertNull(StringUtils.appendSegmentToPath(null,null)); + } +} diff --git a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java index 754bb63652..c3dfc33a37 100644 --- a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java +++ b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java @@ -16,21 +16,11 @@ package org.citrusframework.json; -import java.io.IOException; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Set; - -import org.citrusframework.common.InitializingPhase; -import org.citrusframework.common.Named; -import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.json.schema.SimpleJsonSchema; -import org.citrusframework.spi.ClasspathResourceResolver; +import org.citrusframework.repository.BaseRepository; import org.citrusframework.spi.Resource; -import org.citrusframework.spi.Resources; -import org.citrusframework.util.FileUtils; -import org.citrusframework.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,70 +28,35 @@ * Schema repository holding a set of json schema resources known in the test scope. * @since 2.7.3 */ -public class JsonSchemaRepository implements Named, InitializingPhase { +public class JsonSchemaRepository extends BaseRepository { - /** This repositories name in the Spring application context */ - private String name; + private static final String DEFAULT_NAME = "jsonSchemaRepository"; /** List of schema resources */ private List schemas = new ArrayList<>(); - /** List of location patterns that will be translated to schema resources */ - private List locations = new ArrayList<>(); /** Logger */ private static Logger logger = LoggerFactory.getLogger(JsonSchemaRepository.class); - @Override - public void setName(String name) { - this.name = name; + public JsonSchemaRepository() { + super(DEFAULT_NAME); } - @Override - public void initialize() { - try { - ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver(); - for (String location : locations) { - Resource found = Resources.create(location); - if (found.exists()) { - addSchemas(found); - } else { - Set findings; - if (StringUtils.hasText(FileUtils.getFileExtension(location))) { - String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*"); - String basePath = FileUtils.getBasePath(location); - findings = resourceResolver.getResources(basePath, fileNamePattern); - } else { - findings = resourceResolver.getResources(location); - } - - for (Path resource : findings) { - addSchemas(Resources.fromClasspath(resource.toString())); - } - } - } - } catch (IOException e) { - throw new CitrusRuntimeException("Failed to initialize Json schema repository", e); - } - } - private void addSchemas(Resource resource) { + protected void addRepository(Resource resource) { if (resource.getLocation().endsWith(".json")) { if (logger.isDebugEnabled()) { - logger.debug("Loading json schema resource " + resource.getLocation()); + logger.debug("Loading json schema resource '{}'", resource.getLocation()); } SimpleJsonSchema simpleJsonSchema = new SimpleJsonSchema(resource); simpleJsonSchema.initialize(); schemas.add(simpleJsonSchema); } else { - logger.warn("Skipped resource other than json schema for repository (" + resource.getLocation() + ")"); + logger.warn("Skipped resource other than json schema for repository '{}'", resource.getLocation()); } } - public String getName() { - return name; - } - public List getSchemas() { return schemas; } @@ -118,11 +73,7 @@ public static void setLog(Logger logger) { JsonSchemaRepository.logger = logger; } - public List getLocations() { - return locations; - } - - public void setLocations(List locations) { - this.locations = locations; + public void addSchema(SimpleJsonSchema simpleJsonSchema) { + schemas.add(simpleJsonSchema); } } diff --git a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java index 6a7ae17db9..90310c7d3b 100644 --- a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java +++ b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java @@ -34,6 +34,7 @@ import org.citrusframework.validation.json.report.GraciousProcessingReport; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -58,12 +59,21 @@ public class JsonSchemaValidationTest { private JsonSchemaValidation fixture; + private AutoCloseable mocks; + @BeforeMethod void beforeMethodSetup() { - MockitoAnnotations.openMocks(this); + mocks = MockitoAnnotations.openMocks(this); fixture = new JsonSchemaValidation(jsonSchemaFilterMock); } + @AfterMethod + void afterMethod() throws Exception { + if (mocks != null) { + mocks.close(); + } + } + @Test public void testValidJsonMessageSuccessfullyValidated() { // Setup json schema repositories @@ -264,7 +274,7 @@ public void testJsonSchemaFilterIsCalled() { @Test public void testLookup() { Map> validators = SchemaValidator.lookup(); - assertEquals(validators.size(), 1L); + assertEquals(1L, validators.size()); assertNotNull(validators.get("defaultJsonSchemaValidator")); assertEquals(validators.get("defaultJsonSchemaValidator").getClass(), JsonSchemaValidation.class); } diff --git a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java index e696c6316b..7ca3052066 100644 --- a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java +++ b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java @@ -17,20 +17,14 @@ package org.citrusframework.xml; import java.io.IOException; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Set; import javax.xml.parsers.ParserConfigurationException; - -import org.citrusframework.common.InitializingPhase; -import org.citrusframework.common.Named; import org.citrusframework.exceptions.CitrusRuntimeException; -import org.citrusframework.spi.ClasspathResourceResolver; +import org.citrusframework.repository.BaseRepository; import org.citrusframework.spi.Resource; import org.citrusframework.spi.Resources; import org.citrusframework.util.FileUtils; -import org.citrusframework.util.StringUtils; import org.citrusframework.xml.schema.TargetNamespaceSchemaMappingStrategy; import org.citrusframework.xml.schema.WsdlXsdSchema; import org.citrusframework.xml.schema.XsdSchemaMappingStrategy; @@ -48,22 +42,23 @@ * @author Christoph Deppisch */ @SuppressWarnings("unused") -public class XsdSchemaRepository implements Named, InitializingPhase { - /** The name of the repository */ - private String name = "schemaRepository"; +public class XsdSchemaRepository extends BaseRepository { + + private static final String DEFAULT_NAME = "schemaRepository"; /** List of schema resources */ private List schemas = new ArrayList<>(); - /** List of location patterns that will be translated to schema resources */ - private List locations = new ArrayList<>(); - /** Mapping strategy */ private XsdSchemaMappingStrategy schemaMappingStrategy = new TargetNamespaceSchemaMappingStrategy(); /** Logger */ private static final Logger logger = LoggerFactory.getLogger(XsdSchemaRepository.class); + public XsdSchemaRepository() { + super(DEFAULT_NAME); + } + /** * Find the matching schema for document using given schema mapping strategy. * @param doc the document instance to validate. @@ -76,28 +71,8 @@ public boolean canValidate(Document doc) { @Override public void initialize() { + super.initialize(); try { - ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver(); - for (String location : locations) { - Resource found = Resources.create(location); - if (found.exists()) { - addSchemas(found); - } else { - Set findings; - if (StringUtils.hasText(FileUtils.getFileExtension(location))) { - String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*"); - String basePath = FileUtils.getBasePath(location); - findings = resourceResolver.getResources(basePath, fileNamePattern); - } else { - findings = resourceResolver.getResources(location); - } - - for (Path resource : findings) { - addSchemas(Resources.fromClasspath(resource.toString())); - } - } - } - // Add default Citrus message schemas if available on classpath addCitrusSchema("citrus-http-message"); addCitrusSchema("citrus-mail-message"); @@ -105,7 +80,7 @@ public void initialize() { addCitrusSchema("citrus-ssh-message"); addCitrusSchema("citrus-rmi-message"); addCitrusSchema("citrus-jmx-message"); - } catch (SAXException | ParserConfigurationException | IOException e) { + } catch (SAXException | ParserConfigurationException e) { throw new CitrusRuntimeException("Failed to initialize Xsd schema repository", e); } } @@ -114,26 +89,26 @@ public void initialize() { * Adds Citrus message schema to repository if available on classpath. * @param schemaName The name of the schema within the citrus schema package */ - protected void addCitrusSchema(String schemaName) throws IOException, SAXException, ParserConfigurationException { + protected void addCitrusSchema(String schemaName) throws SAXException, ParserConfigurationException { Resource resource = Resources.fromClasspath("classpath:org/citrusframework/schema/" + schemaName + ".xsd"); if (resource.exists()) { addXsdSchema(resource); } } - private void addSchemas(Resource resource) { + protected void addRepository(Resource resource) { if (resource.getLocation().endsWith(".xsd")) { addXsdSchema(resource); } else if (resource.getLocation().endsWith(".wsdl")) { addWsdlSchema(resource); } else { - logger.warn("Skipped resource other than XSD schema for repository (" + resource.getLocation() + ")"); + logger.warn("Skipped resource other than XSD schema for repository '{}'", resource.getLocation()); } } private void addWsdlSchema(Resource resource) { if (logger.isDebugEnabled()) { - logger.debug("Loading WSDL schema resource " + resource.getLocation()); + logger.debug("Loading WSDL schema resource '{}'", resource.getLocation()); } WsdlXsdSchema wsdl = new WsdlXsdSchema(resource); @@ -143,7 +118,7 @@ private void addWsdlSchema(Resource resource) { private void addXsdSchema(Resource resource) { if (logger.isDebugEnabled()) { - logger.debug("Loading XSD schema resource " + resource.getLocation()); + logger.debug("Loading XSD schema resource '{}'", resource.getLocation()); } SimpleXsdSchema schema = new SimpleXsdSchema(new ByteArrayResource(FileUtils.copyToByteArray(resource))); @@ -186,33 +161,4 @@ public void setSchemaMappingStrategy(XsdSchemaMappingStrategy schemaMappingStrat public XsdSchemaMappingStrategy getSchemaMappingStrategy() { return schemaMappingStrategy; } - - @Override - public void setName(String name) { - this.name = name; - } - - /** - * Gets the name. - * @return the name to get. - */ - public String getName() { - return name; - } - - /** - * Gets the locations. - * @return the locations to get. - */ - public List getLocations() { - return locations; - } - - /** - * Sets the locations. - * @param locations the locations to set - */ - public void setLocations(List locations) { - this.locations = locations; - } }