diff --git a/connectors/citrus-openapi/pom.xml b/connectors/citrus-openapi/pom.xml index fdeb577917..19c51f6355 100644 --- a/connectors/citrus-openapi/pom.xml +++ b/connectors/citrus-openapi/pom.xml @@ -45,6 +45,11 @@ io.apicurio apicurio-data-models + + com.atlassian.oai + swagger-request-validator-core + 2.40.0 + com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -76,12 +81,6 @@ ${project.version} test - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - 2.17.0 - compile - diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java new file mode 100644 index 0000000000..ba5953da5b --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java @@ -0,0 +1,186 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static java.lang.String.format; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A registry to store objects by OpenApi paths. The registry uses a digital tree data structure + * that performs path matching with variable placeholders. Variable + * placeholders must be enclosed in curly braces '{}', e.g., '/api/v1/pet/{id}'. This data structure + * is optimized for matching paths efficiently, handling both static and dynamic segments. + */ +public class OpenApiPathRegistry { + + private static final Logger logger = LoggerFactory.getLogger(OpenApiPathRegistry.class); + + private final RegistryNode root = new RegistryNode(); + + private final Map allPaths = new ConcurrentHashMap<>(); + + public T search(String path) { + RegistryNode trieNode = internalSearch(path); + return trieNode != null ? trieNode.value : null; + } + + RegistryNode internalSearch(String path) { + String[] segments = path.split("/"); + return searchHelper(root, segments, 0); + } + + public boolean insert(String path, T value) { + return insertInternal(path, value) != null; + } + + RegistryNode insertInternal(String path, T value) { + + if (path == null || value == null) { + return null; + } + + String[] segments = path.split("/"); + RegistryNode node = root; + + if (!allPaths.isEmpty() && (isPathAlreadyContainedWithDifferentValue(path, value) + || isPathMatchedByOtherPath(path, value))) { + return null; + } + + allPaths.put(path, value); + StringBuilder builder = new StringBuilder(); + for (String segment : segments) { + if (builder.isEmpty() || builder.charAt(builder.length() - 1) != '/') { + builder.append("/"); + } + builder.append(segment); + + if (!node.children.containsKey(segment)) { + RegistryNode trieNode = new RegistryNode(); + trieNode.path = builder.toString(); + node.children.put(segment, trieNode); + } + node = node.children.get(segment); + } + + // Sanity check to disallow overwrite of existing values + if (node.value != null && !node.value.equals(value)) { + throw new CitrusRuntimeException(format( + "Illegal attempt to overwrite an existing node value. This is probably a bug. path=%s value=%s", + node.path, node.value)); + } + node.value = value; + + return node; + } + + /** + * Tests if the path is either matching an existing path or any existing path matches the given + * patch. + *

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

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

+ *

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

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

+ * If no exact match is found: + *

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

+ *

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

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