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.apicurioapicurio-data-models
+
+ com.atlassian.oai
+ swagger-request-validator-core
+ 2.40.0
+ com.fasterxml.jackson.datatypejackson-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