diff --git a/connectors/citrus-openapi/pom.xml b/connectors/citrus-openapi/pom.xml
index bf85267d78..19c51f6355 100644
--- a/connectors/citrus-openapi/pom.xml
+++ b/connectors/citrus-openapi/pom.xml
@@ -41,11 +41,20 @@
${project.version}provided
-
io.apicurioapicurio-data-models
+
+ com.atlassian.oai
+ swagger-request-validator-core
+ 2.40.0
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ 2.17.0
+
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java
new file mode 100644
index 0000000000..d6802ed996
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiConstants.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi;
+
+public abstract class OpenApiConstants {
+
+ public static final String TYPE_ARRAY = "array";
+ public static final String TYPE_BOOLEAN = "boolean";
+ public static final String TYPE_INTEGER = "integer";
+ public static final String TYPE_NUMBER = "number";
+ public static final String TYPE_OBJECT = "object";
+ public static final String TYPE_STRING = "string";
+
+ public static final String FORMAT_INT32 = "int32";
+ public static final String FORMAT_INT64 = "int64";
+ public static final String FORMAT_FLOAT = "float";
+ public static final String FORMAT_DOUBLE = "double";
+ public static final String FORMAT_DATE = "date";
+ public static final String FORMAT_DATE_TIME = "date-time";
+ public static final String FORMAT_UUID = "uuid";
+
+ /**
+ * Prevent instantiation.
+ */
+ private OpenApiConstants() {
+ }
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java
new file mode 100644
index 0000000000..6218baaceb
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiPathRegistry.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi;
+
+import org.citrusframework.exceptions.CitrusRuntimeException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static java.lang.String.format;
+
+/**
+ * A registry to store objects by OpenApi paths. The registry uses a digital tree data structure
+ * that performs path matching with variable placeholders. Variable
+ * placeholders must be enclosed in curly braces '{}', e.g., '/api/v1/pet/{id}'. This data structure
+ * is optimized for matching paths efficiently, handling both static and dynamic segments.
+ *
+ * This class is currently not in use but may serve scenarios where a path needs to be mapped to an
+ * OasOperation without explicit knowledge of the API to which the path belongs.
+ * It could be utilized, for instance, in implementing an OAS message validator based on
+ * {@link org.citrusframework.validation.AbstractMessageValidator}.
+ */
+public class OpenApiPathRegistry {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenApiPathRegistry.class);
+
+ private final RegistryNode root = new RegistryNode();
+
+ private final Map allPaths = new ConcurrentHashMap<>();
+
+ public T search(String path) {
+ RegistryNode trieNode = internalSearch(path);
+ return trieNode != null ? trieNode.value : null;
+ }
+
+ RegistryNode internalSearch(String path) {
+ String[] segments = path.split("/");
+ return searchHelper(root, segments, 0);
+ }
+
+ public boolean insert(String path, T value) {
+ return insertInternal(path, value) != null;
+ }
+
+ RegistryNode insertInternal(String path, T value) {
+
+ if (path == null || value == null) {
+ return null;
+ }
+
+ String[] segments = path.split("/");
+ RegistryNode node = root;
+
+ if (!allPaths.isEmpty() && (isPathAlreadyContainedWithDifferentValue(path, value)
+ || isPathMatchedByOtherPath(path, value))) {
+ return null;
+ }
+
+ allPaths.put(path, value);
+ StringBuilder builder = new StringBuilder();
+ for (String segment : segments) {
+ if (builder.isEmpty() || builder.charAt(builder.length() - 1) != '/') {
+ builder.append("/");
+ }
+ builder.append(segment);
+
+ if (!node.children.containsKey(segment)) {
+ RegistryNode trieNode = new RegistryNode();
+ trieNode.path = builder.toString();
+ node.children.put(segment, trieNode);
+ }
+ node = node.children.get(segment);
+ }
+
+ // Sanity check to disallow overwrite of existing values
+ if (node.value != null && !node.value.equals(value)) {
+ throw new CitrusRuntimeException(format(
+ "Illegal attempt to overwrite an existing node value. This is probably a bug. path=%s value=%s",
+ node.path, node.value));
+ }
+ node.value = value;
+
+ return node;
+ }
+
+ /**
+ * Tests if the path is either matching an existing path or any existing path matches the given
+ * patch.
+ *
+ * For example '/a/b' does not match '/{a}/{b}', but '/{a}/{b}' matches '/a/b'.
+ */
+ private boolean isPathMatchedByOtherPath(String path, T value) {
+
+ // Does the given path match any existing
+ RegistryNode currentValue = internalSearch(path);
+ if (currentValue != null && !Objects.equals(path, currentValue.path)) {
+ logger.error(
+ "Attempt to insert an equivalent path potentially overwriting an existing value. Value for path is ignored: path={}, value={} currentValue={} ",
+ path, currentValue, value);
+ return true;
+ }
+
+ // Does any existing match the path.
+ OpenApiPathRegistry tmpTrie = new OpenApiPathRegistry<>();
+ tmpTrie.insert(path, value);
+
+ List allMatching = allPaths.keySet().stream()
+ .filter(existingPath -> {
+ RegistryNode trieNode = tmpTrie.internalSearch(existingPath);
+ return trieNode != null && !existingPath.equals(trieNode.path);
+ }).map(existingPath -> "'" + existingPath + "'").toList();
+ if (!allMatching.isEmpty() && logger.isErrorEnabled()) {
+ logger.error(
+ "Attempt to insert an equivalent path overwritten by existing paths. Value for path is ignored: path={}, value={} existingPaths=[{}]",
+ path, currentValue, String.join(",", allMatching));
+
+ }
+
+ return !allMatching.isEmpty();
+ }
+
+ private boolean isPathAlreadyContainedWithDifferentValue(String path, T value) {
+ T currentValue = allPaths.get(path);
+ if (currentValue != null) {
+ if (value.equals(currentValue)) {
+ return false;
+ }
+ logger.error(
+ "Attempt to overwrite value for path is ignored: path={}, value={} currentValue={} ",
+ path, currentValue, value);
+ return true;
+ }
+ return false;
+ }
+
+ private RegistryNode searchHelper(RegistryNode node, String[] segments, int index) {
+ if (node == null) {
+ return null;
+ }
+ if (index == segments.length) {
+ return node;
+ }
+
+ String segment = segments[index];
+
+ // Exact match
+ if (node.children.containsKey(segment)) {
+ RegistryNode foundNode = searchHelper(node.children.get(segment), segments, index + 1);
+ if (foundNode != null && foundNode.value != null) {
+ return foundNode;
+ }
+ }
+
+ // Variable match
+ for (String key : node.children.keySet()) {
+ if (key.startsWith("{") && key.endsWith("}")) {
+ RegistryNode foundNode = searchHelper(node.children.get(key), segments, index + 1);
+ if (foundNode != null && foundNode.value != null) {
+ return foundNode;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ class RegistryNode {
+ Map children = new HashMap<>();
+ String path;
+ T value = null;
+ }
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java
new file mode 100644
index 0000000000..20f845d604
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiRepository.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi;
+
+import org.citrusframework.repository.BaseRepository;
+import org.citrusframework.spi.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 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
+ */
+ private final List openApiSpecifications = new ArrayList<>();
+
+ /**
+ * An optional context path, used for each api, without taking into account any
+ * {@link OpenApiSpecification} specific context path.
+ */
+ private String rootContextPath;
+
+ private boolean requestValidationEnabled = true;
+
+ private boolean responseValidationEnabled = true;
+
+ public OpenApiRepository() {
+ super(DEFAULT_NAME);
+ }
+
+ public String getRootContextPath() {
+ return rootContextPath;
+ }
+
+ 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.setApiRequestValidationEnabled(requestValidationEnabled);
+ openApiSpecification.setApiResponseValidationEnabled(responseValidationEnabled);
+ openApiSpecification.setRootContextPath(rootContextPath);
+
+ this.openApiSpecifications.add(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..17c79ba4c3 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiResourceLoader.java
@@ -16,34 +16,41 @@
package org.citrusframework.openapi;
-import java.io.IOException;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.security.KeyManagementException;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.util.Objects;
-import javax.net.ssl.HttpsURLConnection;
-import javax.net.ssl.SSLContext;
-
+import 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 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;
import org.springframework.http.MediaType;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.security.KeyManagementException;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Objects;
+
/**
* Loads Open API specifications from different locations like file resource or web resource.
* @author Christoph Deppisch
*/
public final class OpenApiResourceLoader {
+ private static final RawResolver RAW_RESOLVER = new RawResolver();
+
+ private static final OasResolver OAS_RESOLVER = new OasResolver();
+
/**
* Prevent instantiation of utility class.
*/
@@ -53,21 +60,36 @@ private OpenApiResourceLoader() {
/**
* Loads the specification from a file resource. Either classpath or file system resource path is supported.
- * @param resource
- * @return
*/
public static OasDocument fromFile(String resource) {
- return fromFile(FileUtils.getFileResource(resource));
+ return fromFile(FileUtils.getFileResource(resource), OAS_RESOLVER);
}
/**
- * Loads the specification from a file resource. Either classpath or file system resource path is supported.
- * @param resource
- * @return
+ * Loads the raw specification from a file resource. Either classpath or file system resource path is supported.
+ */
+ public static String rawFromFile(String resource) {
+ return fromFile(FileUtils.getFileResource(resource),
+ RAW_RESOLVER);
+ }
+
+ /**
+ * Loads the specification from a resource.
*/
public static OasDocument fromFile(Resource resource) {
+ return fromFile(resource, OAS_RESOLVER);
+ }
+
+ /**
+ * Loads the raw specification from a resource.
+ */
+ 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);
}
@@ -75,10 +97,19 @@ public static OasDocument fromFile(Resource resource) {
/**
* Loads specification from given web URL location.
- * @param url
- * @return
*/
public static OasDocument fromWebResource(URL url) {
+ return fromWebResource(url, OAS_RESOLVER);
+ }
+
+ /**
+ * Loads raw specification from given web URL location.
+ */
+ 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();
@@ -87,13 +118,13 @@ 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())));
+ throw new IllegalStateException("Failed to retrieve Open API specification: " + url,
+ 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);
+ throw new IllegalStateException("Failed to retrieve Open API specification: " + url, e);
} finally {
if (con != null) {
con.disconnect();
@@ -103,18 +134,27 @@ public static OasDocument fromWebResource(URL url) {
/**
* Loads specification from given web URL location using secured Http connection.
- * @param url
- * @return
*/
public static OasDocument fromSecuredWebResource(URL url) {
+ return fromSecuredWebResource(url, OAS_RESOLVER);
+ }
+
+ /**
+ * Loads raw specification from given web URL location using secured Http connection.
+ */
+ 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);
@@ -125,15 +165,15 @@ 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())));
+ throw new IllegalStateException("Failed to retrieve Open API specification: " + url,
+ 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);
} catch (IOException e) {
- throw new IllegalStateException("Failed to retrieve Open API specification: " + url.toString(), e);
+ throw new IllegalStateException("Failed to retrieve Open API specification: " + url, e);
} finally {
if (con != null) {
con.disconnect();
@@ -141,16 +181,63 @@ 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..02441f1115
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSettings.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi;
+
+import static java.lang.Boolean.parseBoolean;
+
+/**
+ * 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 isRequestValidationEnabledGlobally() {
+ 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 00c5a1c382..f27f1bed48 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecification.java
@@ -16,35 +16,119 @@
package org.citrusframework.openapi;
+import static org.citrusframework.openapi.OpenApiSettings.isGenerateOptionalFieldsGlobally;
+import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledGlobally;
+import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally;
+import static org.citrusframework.openapi.OpenApiSettings.isValidateOptionalFieldsGlobally;
+
+import io.apicurio.datamodels.core.models.common.Info;
+import io.apicurio.datamodels.openapi.models.OasDocument;
+import io.apicurio.datamodels.openapi.models.OasOperation;
import java.net.MalformedURLException;
+import java.net.URI;
import java.net.URL;
+import java.util.Collection;
import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
import java.util.Optional;
-
-import 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.util.OpenApiUtils;
+import org.citrusframework.openapi.validation.SwaggerOpenApiValidationContext;
+import org.citrusframework.openapi.validation.SwaggerOpenApiValidationContextLoader;
import org.citrusframework.spi.Resource;
import org.citrusframework.spi.Resources;
+import org.citrusframework.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* OpenApi specification resolves URL or local file resources to a specification document.
+ *
+ * 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 {
- /** URL to load the OpenAPI specification */
+ 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
+ */
private String specUrl;
private String httpClient;
private String requestUrl;
+ /**
+ * The optional root context path to which the OpenAPI is hooked. This path is prepended to the
+ * base path specified in the OpenAPI configuration. If no root context path is specified, only
+ * the base path and additional segments are used.
+ */
+ private String rootContextPath;
+
private OasDocument openApiDoc;
- private boolean generateOptionalFields = true;
+ private SwaggerOpenApiValidationContext swaggerOpenApiValidationContext;
+
+ private boolean generateOptionalFields = isGenerateOptionalFieldsGlobally();
+
+ private boolean validateOptionalFields = isValidateOptionalFieldsGlobally();
+
+ /**
+ * Flag to indicate, whether request validation is enabled on api level. Api level overrules global
+ * level and may be overruled by request level.
+ */
+ private boolean apiRequestValidationEnabled = isRequestValidationEnabledGlobally();
+
+ /**
+ * Flag to indicate, whether response validation is enabled on api level. Api level overrules global
+ * level and may be overruled by request level.
+ */
+ private boolean apiResponseValidationEnabled = isResponseValidationEnabledGlobally();
- private boolean validateOptionalFields = true;
+ private final Set aliases = Collections.synchronizedSet(new HashSet<>());
+
+ /**
+ * Maps the identifier (id) of an operation to OperationPathAdapters. Two different keys may be
+ * used for each operation. Refer to
+ * {@link org.citrusframework.openapi.OpenApiSpecification#storeOperationPathAdapter} for more
+ * details.
+ */
+ private final Map operationIdToOperationPathAdapter = new ConcurrentHashMap<>();
+
+ /**
+ * Stores the unique identifier (uniqueId) of an operation, derived from its HTTP method and
+ * path. This identifier can always be determined and is therefore safe to use, even for
+ * operations without an optional operationId defined.
+ */
+ private final Map operationToUniqueId = new ConcurrentHashMap<>();
public static OpenApiSpecification from(String specUrl) {
OpenApiSpecification specification = new OpenApiSpecification();
@@ -56,15 +140,23 @@ public static OpenApiSpecification from(String specUrl) {
public static OpenApiSpecification from(URL specUrl) {
OpenApiSpecification specification = new OpenApiSpecification();
OasDocument openApiDoc;
- if (specUrl.getProtocol().startsWith("https")) {
+ SwaggerOpenApiValidationContext swaggerOpenApiValidationContext;
+ if (specUrl.getProtocol().startsWith(HTTPS)) {
openApiDoc = OpenApiResourceLoader.fromSecuredWebResource(specUrl);
+ swaggerOpenApiValidationContext = SwaggerOpenApiValidationContextLoader.fromSecuredWebResource(specUrl);
} else {
openApiDoc = OpenApiResourceLoader.fromWebResource(specUrl);
+ swaggerOpenApiValidationContext = SwaggerOpenApiValidationContextLoader.fromWebResource(specUrl);
}
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.setSwaggerOpenApiValidationContext(swaggerOpenApiValidationContext);
+ specification.setRequestUrl(
+ String.format("%s://%s%s%s", specUrl.getProtocol(), specUrl.getHost(),
+ specUrl.getPort() > 0 ? ":" + specUrl.getPort() : "",
+ OasModelHelper.getBasePath(openApiDoc)));
return specification;
}
@@ -74,21 +166,24 @@ public static OpenApiSpecification from(Resource resource) {
OasDocument openApiDoc = OpenApiResourceLoader.fromFile(resource);
specification.setOpenApiDoc(openApiDoc);
+ specification.setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromFile(resource));
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;
}
@@ -99,43 +194,60 @@ 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);
+ if (resolvedSpecUrl.startsWith(HTTP)) {
+ URL specWebResource = toSpecUrl(resolvedSpecUrl);
+ if (resolvedSpecUrl.startsWith(HTTPS)) {
+ initApiDoc(
+ () -> OpenApiResourceLoader.fromSecuredWebResource(specWebResource));
+
+ setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromSecuredWebResource(specWebResource));
+ } else {
+ initApiDoc(() -> OpenApiResourceLoader.fromWebResource(specWebResource));
+ setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromWebResource(specWebResource));
+ }
+
+ 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));
+ setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContextLoader.fromFile(resource));
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)));
}
}
}
@@ -143,8 +255,85 @@ public OasDocument getOpenApiDoc(TestContext context) {
return openApiDoc;
}
- public void setOpenApiDoc(OasDocument openApiDoc) {
- this.openApiDoc = openApiDoc;
+ public SwaggerOpenApiValidationContext getSwaggerOpenApiValidationContext() {
+ return swaggerOpenApiValidationContext;
+ }
+
+
+ // provided for testing
+ URL toSpecUrl(String resolvedSpecUrl) {
+ try {
+ return URI.create(resolvedSpecUrl).toURL();
+ } catch (MalformedURLException e) {
+ throw new IllegalStateException(
+ "Failed to retrieve Open API specification as web resource: " + resolvedSpecUrl, e);
+ }
+ }
+
+ void setOpenApiDoc(OasDocument openApiDoc) {
+ initApiDoc(() -> openApiDoc);
+ }
+
+ private void setSwaggerOpenApiValidationContext(SwaggerOpenApiValidationContext swaggerOpenApiValidationContext) {
+ this.swaggerOpenApiValidationContext = swaggerOpenApiValidationContext;
+ this.swaggerOpenApiValidationContext.setResponseValidationEnabled(apiResponseValidationEnabled);
+ this.swaggerOpenApiValidationContext.setRequestValidationEnabled(apiRequestValidationEnabled);
+ }
+
+ private void initApiDoc(Supplier openApiDocSupplier) {
+ this.openApiDoc = openApiDocSupplier.get();
+ this.aliases.addAll(collectAliases(openApiDoc));
+ initPathLookups();
+ }
+
+ private void initPathLookups() {
+
+ if (this.openApiDoc == null) {
+ return;
+ }
+
+ operationIdToOperationPathAdapter.clear();
+ OasModelHelper.visitOasOperations(this.openApiDoc, (oasPathItem, oasOperation) -> {
+ String path = oasPathItem.getPath();
+
+ if (StringUtils.isEmpty(path)) {
+ logger.warn("Skipping path item without path.");
+ return;
+ }
+
+ for (Map.Entry operationEntry : OasModelHelper.getOperationMap(
+ oasPathItem).entrySet()) {
+ storeOperationPathAdapter(operationEntry.getValue(), path);
+ }
+ });
+ }
+
+ /**
+ * Stores an {@link OperationPathAdapter} in
+ * {@link org.citrusframework.openapi.OpenApiSpecification#operationIdToOperationPathAdapter}.
+ * The adapter is stored using two keys: the operationId (optional) and the full path of the
+ * operation, including the method. The full path is always determinable and thus can always be
+ * safely used.
+ *
+ * @param operation The {@link OperationPathAdapter} to store.
+ * @param path The full path of the operation, including the method.
+ */
+ private void storeOperationPathAdapter(OasOperation operation, String path) {
+
+ String basePath = OasModelHelper.getBasePath(openApiDoc);
+ String fullOperationPath = StringUtils.appendSegmentToUrlPath(basePath, path);
+
+ OperationPathAdapter operationPathAdapter = new OperationPathAdapter(path, rootContextPath,
+ StringUtils.appendSegmentToUrlPath(rootContextPath, path), operation);
+
+ String uniqueOperationId = OpenApiUtils.createFullPathOperationIdentifier(fullOperationPath,
+ operation);
+ operationToUniqueId.put(operation, uniqueOperationId);
+
+ operationIdToOperationPathAdapter.put(uniqueOperationId, operationPathAdapter);
+ if (StringUtils.hasText(operation.operationId)) {
+ operationIdToOperationPathAdapter.put(operation.operationId, operationPathAdapter);
+ }
}
public String getSpecUrl() {
@@ -175,6 +364,28 @@ public void setRequestUrl(String requestUrl) {
this.requestUrl = requestUrl;
}
+ public boolean isApiRequestValidationEnabled() {
+ return apiRequestValidationEnabled;
+ }
+
+ public void setApiRequestValidationEnabled(boolean enabled) {
+ this.apiRequestValidationEnabled = enabled;
+ if (this.swaggerOpenApiValidationContext != null) {
+ this.swaggerOpenApiValidationContext.setRequestValidationEnabled(enabled);
+ }
+ }
+
+ public boolean isApiResponseValidationEnabled() {
+ return apiResponseValidationEnabled;
+ }
+
+ public void setApiResponseValidationEnabled(boolean enabled) {
+ this.apiResponseValidationEnabled = enabled;
+ if (this.swaggerOpenApiValidationContext != null) {
+ this.swaggerOpenApiValidationContext.setResponseValidationEnabled(enabled);
+ }
+ }
+
public boolean isGenerateOptionalFields() {
return generateOptionalFields;
}
@@ -190,4 +401,67 @@ public boolean isValidateOptionalFields() {
public void setValidateOptionalFields(boolean validateOptionalFields) {
this.validateOptionalFields = validateOptionalFields;
}
+
+ public String getRootContextPath() {
+ return rootContextPath;
+ }
+
+ 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 OpenApiSpecification withRootContext(String rootContextPath) {
+ setRootContextPath(rootContextPath);
+ return this;
+ }
+
+ public String getUniqueId(OasOperation oasOperation) {
+ return operationToUniqueId.get(oasOperation);
+ }
+
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java
new file mode 100644
index 0000000000..011a2dc7fd
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationAdapter.java
@@ -0,0 +1,27 @@
+/*
+ * 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 record OpenApiSpecificationAdapter(OpenApiSpecification openApiSpecification, T entity) {
+
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.java
new file mode 100644
index 0000000000..ceeb9286d5
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSpecificationProcessor.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;
+
+import org.citrusframework.spi.ResourcePathTypeResolver;
+import org.citrusframework.spi.TypeResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+
+/**
+ * Interface for processing OpenAPI specifications.
+ *
+ * This interface is designed to be implemented by custom processors that handle OpenAPI specifications.
+ * Implementations of this interface are discovered by the standard citrus SPI mechanism.
+ *
+ */
+public interface OpenApiSpecificationProcessor {
+
+ /** Logger */
+ Logger logger = LoggerFactory.getLogger(OpenApiSpecificationProcessor.class);
+
+ /** OpenAPI processors resource lookup path */
+ String RESOURCE_PATH = "META-INF/citrus/openapi/processor";
+
+ /** Type resolver to find OpenAPI processors on classpath via resource path lookup */
+ TypeResolver TYPE_RESOLVER = new ResourcePathTypeResolver(RESOURCE_PATH);
+
+ void process(OpenApiSpecification openApiSpecification);
+
+ /**
+ * Resolves all available processors from resource path lookup. Scans classpath for processors meta information
+ * and instantiates those processors.
+ */
+ static Map lookup() {
+ Map processors = TYPE_RESOLVER.resolveAll("", TypeResolver.DEFAULT_TYPE_PROPERTY, "name");
+
+ if (logger.isDebugEnabled()) {
+ processors.forEach((k, v) -> logger.debug(String.format("Found openapi specification processor '%s' as %s", k, v.getClass())));
+ }
+
+ return processors;
+ }
+
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java
index e966960fdf..9020d555e3 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiSupport.java
@@ -16,9 +16,6 @@
package org.citrusframework.openapi;
-import java.util.Collection;
-import java.util.Map;
-
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
@@ -33,6 +30,9 @@
import org.yaml.snakeyaml.nodes.Tag;
import org.yaml.snakeyaml.representer.Representer;
+import java.util.Collection;
+import java.util.Map;
+
public class OpenApiSupport {
private static final ObjectMapper OBJECT_MAPPER;
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java
index c240fe80c2..88e621dc12 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestDataGenerator.java
@@ -16,337 +16,66 @@
package org.citrusframework.openapi;
-import java.util.Map;
-import java.util.stream.Collectors;
-
import io.apicurio.datamodels.openapi.models.OasSchema;
import org.citrusframework.CitrusSettings;
import org.citrusframework.context.TestContext;
-import org.citrusframework.openapi.model.OasModelHelper;
-import org.springframework.util.CollectionUtils;
-import org.springframework.util.StringUtils;
+import org.citrusframework.openapi.random.RandomContext;
/**
- * Generates proper payloads and validation expressions based on Open API specification rules. Creates outbound payloads
- * with generated random test data according to specification and creates inbound payloads with proper validation expressions to
- * enforce the specification rules.
- *
- * @author Christoph Deppisch
+ * Generates proper payloads expressions based on Open API specification rules.
*/
-public class OpenApiTestDataGenerator {
-
- /**
- * Creates payload from schema for outbound message.
- * @param schema
- * @param definitions
- * @return
- */
- public static String createOutboundPayload(OasSchema schema, Map definitions,
- OpenApiSpecification specification) {
- if (OasModelHelper.isReferenceType(schema)) {
- OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref));
- return createOutboundPayload(resolved, definitions, specification);
- }
-
- StringBuilder payload = new StringBuilder();
- if (OasModelHelper.isObjectType(schema)) {
- payload.append("{");
-
- if (schema.properties != null) {
- for (Map.Entry entry : schema.properties.entrySet()) {
- if (specification.isGenerateOptionalFields() || isRequired(schema, entry.getKey())) {
- payload.append("\"")
- .append(entry.getKey())
- .append("\": ")
- .append(createRandomValueExpression(entry.getValue(), definitions, true, specification))
- .append(",");
- }
- }
- }
+public abstract class OpenApiTestDataGenerator {
- if (payload.toString().endsWith(",")) {
- payload.replace(payload.length() - 1, payload.length(), "");
- }
-
- payload.append("}");
- } else if (OasModelHelper.isArrayType(schema)) {
- payload.append("[");
- payload.append(createRandomValueExpression((OasSchema) schema.items, definitions, true, specification));
- payload.append("]");
- } else {
- payload.append(createRandomValueExpression(schema, definitions, true, specification));
- }
-
- return payload.toString();
+ private OpenApiTestDataGenerator() {
+ // Static access only
}
/**
- * Use test variable with given name if present or create value from schema with random values
- * @param schema
- * @param definitions
- * @param quotes
- * @return
- */
- public static String createRandomValueExpression(String name, OasSchema schema, Map definitions,
- boolean quotes, OpenApiSpecification specification, TestContext context) {
- if (context.getVariables().containsKey(name)) {
- return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX;
- }
-
- return createRandomValueExpression(schema, definitions, quotes, specification);
- }
-
- /**
- * Create payload from schema with random values.
- * @param schema
- * @param definitions
- * @param quotes
- * @return
+ * Creates payload from schema for outbound message.
*/
- public static String createRandomValueExpression(OasSchema schema, Map definitions, boolean quotes,
- OpenApiSpecification specification) {
- if (OasModelHelper.isReferenceType(schema)) {
- OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref));
- return createRandomValueExpression(resolved, definitions, quotes, specification);
- }
-
- StringBuilder payload = new StringBuilder();
- if (OasModelHelper.isObjectType(schema) || OasModelHelper.isArrayType(schema)) {
- payload.append(createOutboundPayload(schema, definitions, specification));
- } else if ("string".equals(schema.type)) {
- if (quotes) {
- payload.append("\"");
- }
-
- if (schema.format != null && schema.format.equals("date")) {
- payload.append("citrus:currentDate('yyyy-MM-dd')");
- } else if (schema.format != null && schema.format.equals("date-time")) {
- payload.append("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ss')");
- } else if (StringUtils.hasText(schema.pattern)) {
- payload.append("citrus:randomValue(").append(schema.pattern).append(")");
- } else if (!CollectionUtils.isEmpty(schema.enum_)) {
- payload.append("citrus:randomEnumValue(").append(schema.enum_.stream().map(value -> "'" + value + "'").collect(Collectors.joining(","))).append(")");
- } else if (schema.format != null && schema.format.equals("uuid")) {
- payload.append("citrus:randomUUID()");
- } else {
- payload.append("citrus:randomString(").append(schema.maxLength != null && schema.maxLength.intValue() > 0 ? schema.maxLength : (schema.minLength != null && schema.minLength.intValue() > 0 ? schema.minLength : 10)).append(")");
- }
-
- if (quotes) {
- payload.append("\"");
- }
- } else if ("integer".equals(schema.type) || "number".equals(schema.type)) {
- payload.append("citrus:randomNumber(8)");
- } else if ("boolean".equals(schema.type)) {
- payload.append("citrus:randomEnumValue('true', 'false')");
- } else if (quotes) {
- payload.append("\"\"");
- }
-
- return payload.toString();
+ public static String createOutboundPayload(OasSchema schema,
+ OpenApiSpecification specification) {
+ RandomContext randomContext = new RandomContext(specification, true);
+ randomContext.generate(schema);
+ return randomContext.getRandomModelBuilder().write();
}
/**
- * Creates control payload from schema for validation.
- * @param schema
- * @param definitions
- * @return
- */
- public static String createInboundPayload(OasSchema schema, Map definitions,
- OpenApiSpecification specification) {
- if (OasModelHelper.isReferenceType(schema)) {
- OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref));
- return createInboundPayload(resolved, definitions, specification);
- }
-
- StringBuilder payload = new StringBuilder();
- if (OasModelHelper.isObjectType(schema)) {
- payload.append("{");
-
- if (schema.properties != null) {
- for (Map.Entry entry : schema.properties.entrySet()) {
- if (specification.isValidateOptionalFields() || isRequired(schema, entry.getKey())) {
- payload.append("\"")
- .append(entry.getKey())
- .append("\": ")
- .append(createValidationExpression(entry.getValue(), definitions, true, specification))
- .append(",");
- }
- }
- }
-
- if (payload.toString().endsWith(",")) {
- payload.replace(payload.length() - 1, payload.length(), "");
- }
-
- payload.append("}");
- } else if (OasModelHelper.isArrayType(schema)) {
- payload.append("[");
- payload.append(createValidationExpression((OasSchema) schema.items, definitions, true, specification));
- payload.append("]");
- } else {
- payload.append(createValidationExpression(schema, definitions, false, specification));
- }
-
- return payload.toString();
- }
-
- /**
- * Checks if given field name is in list of required fields for this schema.
- * @param schema
- * @param field
- * @return
+ * Use test variable with given name if present or create value from schema with random values
*/
- private static boolean isRequired(OasSchema schema, String field) {
- if (schema.required == null) {
- return true;
- }
+ public static String createRandomValueExpression(String name, OasSchema schema, OpenApiSpecification specification,
+ TestContext context) {
- return schema.required.contains(field);
- }
-
- /**
- * Use test variable with given name if present or create validation expression using functions according to schema type and format.
- * @param name
- * @param schema
- * @param definitions
- * @param quotes
- * @param context
- * @return
- */
- public static String createValidationExpression(String name, OasSchema schema, Map definitions,
- boolean quotes, OpenApiSpecification specification,
- TestContext context) {
if (context.getVariables().containsKey(name)) {
return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX;
}
- return createValidationExpression(schema, definitions, quotes, specification);
- }
-
- /**
- * Create validation expression using functions according to schema type and format.
- * @param schema
- * @param definitions
- * @param quotes
- * @return
- */
- public static String createValidationExpression(OasSchema schema, Map definitions, boolean quotes,
- OpenApiSpecification specification) {
- if (OasModelHelper.isReferenceType(schema)) {
- OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref));
- return createValidationExpression(resolved, definitions, quotes, specification);
- }
-
- StringBuilder payload = new StringBuilder();
- if (OasModelHelper.isObjectType(schema)) {
- payload.append("{");
-
- if (schema.properties != null) {
- for (Map.Entry entry : schema.properties.entrySet()) {
- if (specification.isValidateOptionalFields() || isRequired(schema, entry.getKey())) {
- payload.append("\"")
- .append(entry.getKey())
- .append("\": ")
- .append(createValidationExpression(entry.getValue(), definitions, quotes, specification))
- .append(",");
- }
- }
- }
-
- if (payload.toString().endsWith(",")) {
- payload.replace(payload.length() - 1, payload.length(), "");
- }
-
- payload.append("}");
- } else {
- if (quotes) {
- payload.append("\"");
- }
-
- payload.append(createValidationExpression(schema));
-
- if (quotes) {
- payload.append("\"");
- }
- }
+ RandomContext randomContext = new RandomContext(specification, false);
+ randomContext.generate(schema);
+ return randomContext.getRandomModelBuilder().write();
- return payload.toString();
}
/**
- * Create validation expression using functions according to schema type and format.
- * @param schema
- * @return
+ * Use test variable with given name (if present) or create random value expression using
+ * functions according to schema type and format.
*/
- private static String createValidationExpression(OasSchema schema) {
- switch (schema.type) {
- case "string":
- if (schema.format != null && schema.format.equals("date")) {
- return "@matchesDatePattern('yyyy-MM-dd')@";
- } else if (schema.format != null && schema.format.equals("date-time")) {
- return "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ss')@";
- } else if (StringUtils.hasText(schema.pattern)) {
- return String.format("@matches(%s)@", schema.pattern);
- } else if (!CollectionUtils.isEmpty(schema.enum_)) {
- return String.format("@matches(%s)@", String.join("|", schema.enum_));
- } else {
- return "@notEmpty()@";
- }
- case "number":
- case "integer":
- return "@isNumber()@";
- case "boolean":
- return "@matches(true|false)@";
- default:
- return "@ignore@";
- }
- }
+ public static String createRandomValueExpression(String name, OasSchema schema,
+ TestContext context) {
- /**
- * Use test variable with given name (if present) or create random value expression using functions according to
- * schema type and format.
- * @param name
- * @param schema
- * @param context
- * @return
- */
- public static String createRandomValueExpression(String name, OasSchema schema, TestContext context) {
if (context.getVariables().containsKey(name)) {
return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX;
}
- return createRandomValueExpression(schema);
+ RandomContext randomContext = new RandomContext();
+ randomContext.generate(schema);
+ return randomContext.getRandomModelBuilder().write();
}
- /**
- * Create random value expression using functions according to schema type and format.
- * @param schema
- * @return
- */
public static String createRandomValueExpression(OasSchema schema) {
- switch (schema.type) {
- case "string":
- if (schema.format != null && schema.format.equals("date")) {
- return "\"citrus:currentDate('yyyy-MM-dd')\"";
- } else if (schema.format != null && schema.format.equals("date-time")) {
- return "\"citrus:currentDate('yyyy-MM-dd'T'hh:mm:ss')\"";
- } else if (StringUtils.hasText(schema.pattern)) {
- return "\"citrus:randomValue(" + schema.pattern + ")\"";
- } else if (!CollectionUtils.isEmpty(schema.enum_)) {
- return "\"citrus:randomEnumValue(" + (String.join(",", schema.enum_)) + ")\"";
- } else if (schema.format != null && schema.format.equals("uuid")){
- return "citrus:randomUUID()";
- } else {
- return "citrus:randomString(10)";
- }
- case "number":
- case "integer":
- return "citrus:randomNumber(8)";
- case "boolean":
- return "citrus:randomEnumValue('true', 'false')";
- default:
- return "";
- }
+ RandomContext randomContext = new RandomContext();
+ randomContext.generate(schema);
+ return randomContext.getRandomModelBuilder().write();
}
+
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java
new file mode 100644
index 0000000000..95033bb26c
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/OpenApiTestValidationDataGenerator.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi;
+
+import static org.citrusframework.util.StringUtils.hasText;
+import static org.springframework.util.CollectionUtils.isEmpty;
+
+import io.apicurio.datamodels.openapi.models.OasSchema;
+import jakarta.annotation.Nullable;
+import java.util.Map;
+import org.citrusframework.CitrusSettings;
+import org.citrusframework.context.TestContext;
+import org.citrusframework.exceptions.CitrusRuntimeException;
+import org.citrusframework.openapi.model.OasModelHelper;
+
+/**
+ * Generates proper payloads and validation expressions based on Open API specification rules.
+ * Creates outbound payloads with generated random test data according to specification and creates
+ * inbound payloads with proper validation expressions to enforce the specification rules.
+ *
+ * @author Christoph Deppisch
+ */
+public abstract class OpenApiTestValidationDataGenerator {
+
+ private OpenApiTestValidationDataGenerator() {
+ // Static access only
+ }
+
+ /**
+ * Creates control payload from schema for validation.
+ */
+ public static String createInboundPayload(OasSchema schema, Map definitions,
+ OpenApiSpecification specification) {
+ if (OasModelHelper.isReferenceType(schema)) {
+ OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref));
+ return createInboundPayload(resolved, definitions, specification);
+ }
+
+ StringBuilder payload = new StringBuilder();
+ if (OasModelHelper.isObjectType(schema)) {
+ payload.append("{");
+
+ if (schema.properties != null) {
+ for (Map.Entry entry : schema.properties.entrySet()) {
+ if (specification.isValidateOptionalFields() || isRequired(schema,
+ entry.getKey())) {
+ payload.append("\"")
+ .append(entry.getKey())
+ .append("\": ")
+ .append(createValidationExpression(entry.getValue(), definitions, true,
+ specification))
+ .append(",");
+ }
+ }
+ }
+
+ if (payload.toString().endsWith(",")) {
+ payload.replace(payload.length() - 1, payload.length(), "");
+ }
+
+ payload.append("}");
+ } else if (OasModelHelper.isArrayType(schema)) {
+ payload.append("[");
+ payload.append(createValidationExpression((OasSchema) schema.items, definitions, true,
+ specification));
+ payload.append("]");
+ } else {
+ payload.append(createValidationExpression(schema, definitions, false, specification));
+ }
+
+ return payload.toString();
+ }
+
+ /**
+ * Checks if given field name is in list of required fields for this schema.
+ */
+ private static boolean isRequired(OasSchema schema, String field) {
+ if (schema.required == null) {
+ return true;
+ }
+
+ return schema.required.contains(field);
+ }
+
+ /**
+ * Use test variable with given name if present or create validation expression using functions
+ * according to schema type and format.
+ */
+ public static String createValidationExpression(String name, OasSchema schema,
+ Map definitions,
+ boolean quotes, OpenApiSpecification specification,
+ TestContext context) {
+ if (context.getVariables().containsKey(name)) {
+ return CitrusSettings.VARIABLE_PREFIX + name + CitrusSettings.VARIABLE_SUFFIX;
+ }
+
+ return createValidationExpression(schema, definitions, quotes, specification);
+ }
+
+ /**
+ * Create validation expression using functions according to schema type and format.
+ */
+ public static String createValidationExpression(OasSchema schema,
+ Map definitions, boolean quotes,
+ OpenApiSpecification specification) {
+ if (OasModelHelper.isReferenceType(schema)) {
+ OasSchema resolved = definitions.get(OasModelHelper.getReferenceName(schema.$ref));
+ return createValidationExpression(resolved, definitions, quotes, specification);
+ }
+
+ StringBuilder payload = new StringBuilder();
+ if (OasModelHelper.isObjectType(schema)) {
+ payload.append("{");
+
+ if (schema.properties != null) {
+ for (Map.Entry entry : schema.properties.entrySet()) {
+ if (specification.isValidateOptionalFields() || isRequired(schema,
+ entry.getKey())) {
+ payload.append("\"")
+ .append(entry.getKey())
+ .append("\": ")
+ .append(
+ createValidationExpression(entry.getValue(), definitions, quotes,
+ specification))
+ .append(",");
+ }
+ }
+ }
+
+ if (payload.toString().endsWith(",")) {
+ payload.replace(payload.length() - 1, payload.length(), "");
+ }
+
+ payload.append("}");
+ } else {
+ if (quotes) {
+ payload.append("\"");
+ }
+
+ payload.append(createValidationExpression(schema));
+
+ if (quotes) {
+ payload.append("\"");
+ }
+ }
+
+ return payload.toString();
+ }
+
+ /**
+ * Create validation expression using functions according to schema type and format.
+ */
+ private static String createValidationExpression(OasSchema schema) {
+
+ if (OasModelHelper.isCompositeSchema(schema)) {
+ /*
+ * Currently these schemas are not supported by validation expressions. They are supported
+ * by {@link org.citrusframework.openapi.validation.OpenApiValidator} though.
+ */
+ return "@ignore@";
+ }
+
+ switch (schema.type) {
+ case OpenApiConstants.TYPE_STRING :
+ if (schema.format != null && schema.format.equals("date")) {
+ return "@matchesDatePattern('yyyy-MM-dd')@";
+ } else if (schema.format != null && schema.format.equals("date-time")) {
+ return "@matchesDatePattern('yyyy-MM-dd'T'hh:mm:ssZ')@";
+ } else if (hasText(schema.pattern)) {
+ return String.format("@matches(%s)@", schema.pattern);
+ } else if (!isEmpty(schema.enum_)) {
+ return String.format("@matches(%s)@",
+ String.join("|", schema.enum_));
+ } else {
+ return "@notEmpty()@";
+ }
+ case OpenApiConstants.TYPE_NUMBER, OpenApiConstants.TYPE_INTEGER:
+ return "@isNumber()@";
+ case OpenApiConstants.TYPE_BOOLEAN :
+ return "@matches(true|false)@";
+ default:
+ return "@ignore@";
+ }
+ }
+
+ /**
+ * Create validation expression using regex according to schema type and format.
+ */
+ public static String createValidationRegex(String name, @Nullable OasSchema oasSchema) {
+
+ if (oasSchema != null && (OasModelHelper.isReferenceType(oasSchema)
+ || OasModelHelper.isObjectType(oasSchema))) {
+ throw new CitrusRuntimeException(String.format(
+ "Unable to create a validation regex for an reference of object schema '%s'!",
+ name));
+ }
+
+ return createValidationRegex(oasSchema);
+ }
+
+ public static String createValidationRegex(@Nullable OasSchema schema) {
+
+ if (schema == null) {
+ return "";
+ }
+
+ switch (schema.type) {
+ case OpenApiConstants.TYPE_STRING:
+ if (schema.format != null && schema.format.equals("date")) {
+ return "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])";
+ } else if (schema.format != null && schema.format.equals("date-time")) {
+ return "\\d{4}-\\d{2}-\\d{2}T[01]\\d:[0-5]\\d:[0-5]\\dZ";
+ } else if (hasText(schema.pattern)) {
+ return schema.pattern;
+ } else if (!isEmpty(schema.enum_)) {
+ return "(" + String.join("|", schema.enum_) + ")";
+ } else if (schema.format != null && schema.format.equals("uuid")) {
+ return "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
+ } else {
+ return ".*";
+ }
+ case OpenApiConstants.TYPE_NUMBER:
+ return "[0-9]+\\.?[0-9]*";
+ case OpenApiConstants.TYPE_INTEGER:
+ return "[0-9]+";
+ case OpenApiConstants.TYPE_BOOLEAN:
+ return "(true|false)";
+ default:
+ return "";
+ }
+ }
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java
index cd60dc39b1..4ad4c509f6 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiActionBuilder.java
@@ -16,8 +16,8 @@
package org.citrusframework.openapi.actions;
+import jakarta.annotation.Nullable;
import java.net.URL;
-
import org.citrusframework.TestAction;
import org.citrusframework.TestActionBuilder;
import org.citrusframework.endpoint.Endpoint;
@@ -53,7 +53,6 @@ public OpenApiActionBuilder(OpenApiSpecification specification) {
/**
* Static entrance method for the OpenApi fluent action builder.
- * @return
*/
public static OpenApiActionBuilder openapi() {
return new OpenApiActionBuilder();
@@ -143,7 +142,6 @@ public OpenApiServerActionBuilder server(String httpServer) {
/**
* Sets the bean reference resolver.
- * @param referenceResolver
*/
public OpenApiActionBuilder withReferenceResolver(ReferenceResolver referenceResolver) {
this.referenceResolver = referenceResolver;
@@ -163,15 +161,14 @@ public TestActionBuilder> getDelegate() {
/**
* Specifies the referenceResolver.
- * @param referenceResolver
*/
@Override
- public void setReferenceResolver(ReferenceResolver referenceResolver) {
- if (referenceResolver == null) {
+ public void setReferenceResolver(@Nullable ReferenceResolver referenceResolver) {
+ if (referenceResolver != null) {
this.referenceResolver = referenceResolver;
- if (delegate instanceof ReferenceResolverAware) {
- ((ReferenceResolverAware) delegate).setReferenceResolver(referenceResolver);
+ if (delegate instanceof ReferenceResolverAware referenceResolverAware) {
+ referenceResolverAware.setReferenceResolver(referenceResolver);
}
}
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java
index 3d26d4d349..d6cfe3abc1 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientActionBuilder.java
@@ -16,6 +16,7 @@
package org.citrusframework.openapi.actions;
+import jakarta.annotation.Nullable;
import org.citrusframework.TestAction;
import org.citrusframework.TestActionBuilder;
import org.citrusframework.endpoint.Endpoint;
@@ -135,12 +136,12 @@ public TestActionBuilder> getDelegate() {
* @param referenceResolver
*/
@Override
- public void setReferenceResolver(ReferenceResolver referenceResolver) {
- if (referenceResolver == null) {
+ public void setReferenceResolver(@Nullable ReferenceResolver referenceResolver) {
+ if (referenceResolver != null) {
this.referenceResolver = referenceResolver;
- if (delegate instanceof ReferenceResolverAware) {
- ((ReferenceResolverAware) delegate).setReferenceResolver(referenceResolver);
+ if (delegate instanceof ReferenceResolverAware referenceResolverAware) {
+ referenceResolverAware.setReferenceResolver(referenceResolver);
}
}
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java
index d646202c4d..cf244826c9 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientRequestActionBuilder.java
@@ -16,18 +16,15 @@
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.actions.SendMessageAction;
import org.citrusframework.context.TestContext;
import org.citrusframework.exceptions.CitrusRuntimeException;
import org.citrusframework.http.actions.HttpClientRequestActionBuilder;
@@ -37,6 +34,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 +45,14 @@
*/
public class OpenApiClientRequestActionBuilder extends HttpClientRequestActionBuilder {
+ private final OpenApiSpecification openApiSpec;
+
+ private final String operationId;
+
+ private boolean oasValidationEnabled = true;
+
+ private OpenApiRequestValidationProcessor openApiRequestValidationProcessor;
+
/**
* Default constructor initializes http request message builder.
*/
@@ -56,6 +63,25 @@ public OpenApiClientRequestActionBuilder(OpenApiSpecification openApiSpec, Strin
public OpenApiClientRequestActionBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec,
String operationId) {
super(new OpenApiClientRequestMessageBuilder(httpMessage, openApiSpec, operationId), httpMessage);
+
+ this.openApiSpec = openApiSpec;
+ this.operationId = operationId;
+ }
+
+ @Override
+ public SendMessageAction doBuild() {
+
+ if (oasValidationEnabled && !messageProcessors.contains(openApiRequestValidationProcessor)) {
+ openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId);
+ process(openApiRequestValidationProcessor);
+ }
+
+ return super.doBuild();
+ }
+
+ public OpenApiClientRequestActionBuilder disableOasValidation(boolean disabled) {
+ oasValidationEnabled = !disabled;
+ return this;
}
private static class OpenApiClientRequestMessageBuilder extends HttpMessageBuilder {
@@ -75,65 +101,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 +136,53 @@ 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, 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, 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 1e235868af..302a75e9d9 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiClientResponseActionBuilder.java
@@ -16,27 +16,31 @@
package org.citrusframework.openapi.actions;
-import java.util.Map;
-import java.util.Optional;
-import java.util.regex.Pattern;
-
-import io.apicurio.datamodels.openapi.models.OasDocument;
import io.apicurio.datamodels.openapi.models.OasOperation;
-import io.apicurio.datamodels.openapi.models.OasPathItem;
import io.apicurio.datamodels.openapi.models.OasResponse;
import io.apicurio.datamodels.openapi.models.OasSchema;
+import jakarta.annotation.Nullable;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
import org.citrusframework.CitrusSettings;
+import org.citrusframework.actions.ReceiveMessageAction;
import org.citrusframework.context.TestContext;
import org.citrusframework.exceptions.CitrusRuntimeException;
import org.citrusframework.http.actions.HttpClientResponseActionBuilder;
import org.citrusframework.http.message.HttpMessage;
import org.citrusframework.http.message.HttpMessageBuilder;
import org.citrusframework.message.Message;
+import org.citrusframework.message.MessageType;
import org.citrusframework.openapi.OpenApiSpecification;
-import org.citrusframework.openapi.OpenApiTestDataGenerator;
+import org.citrusframework.openapi.OpenApiTestValidationDataGenerator;
import org.citrusframework.openapi.model.OasModelHelper;
+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;
/**
* @author Christoph Deppisch
@@ -44,16 +48,106 @@
*/
public class OpenApiClientResponseActionBuilder extends HttpClientResponseActionBuilder {
+ private OpenApiResponseValidationProcessor openApiResponseValidationProcessor;
+
+ private final OpenApiSpecification openApiSpec;
+
+ private final String operationId;
+
+ private boolean oasValidationEnabled = true;
+
/**
* Default constructor initializes http response message builder.
*/
- public OpenApiClientResponseActionBuilder(OpenApiSpecification openApiSpec, String operationId, String statusCode) {
+ public OpenApiClientResponseActionBuilder(OpenApiSpecification openApiSpec, String operationId,
+ String statusCode) {
this(new HttpMessage(), openApiSpec, operationId, statusCode);
}
- public OpenApiClientResponseActionBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec,
- String operationId, String statusCode) {
- super(new OpenApiClientResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode), httpMessage);
+ public OpenApiClientResponseActionBuilder(HttpMessage httpMessage,
+ OpenApiSpecification openApiSpec,
+ String operationId, String statusCode) {
+ super(new OpenApiClientResponseMessageBuilder(httpMessage, openApiSpec, operationId,
+ statusCode), httpMessage);
+ this.openApiSpec = openApiSpec;
+ this.operationId = operationId;
+ }
+
+ @Override
+ public ReceiveMessageAction doBuild() {
+
+ if (oasValidationEnabled && !messageProcessors.contains(openApiResponseValidationProcessor)) {
+ openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, operationId);
+ validate(openApiResponseValidationProcessor);
+ }
+
+ return super.doBuild();
+ }
+
+ public OpenApiClientResponseActionBuilder disableOasValidation(boolean disable) {
+ oasValidationEnabled = !disable;
+ ((OpenApiClientResponseMessageBuilder)getMessageBuilderSupport().getMessageBuilder()).setOasValidationEnabled(oasValidationEnabled);
+ return this;
+ }
+
+ public static void fillMessageFromResponse(OpenApiSpecification openApiSpecification,
+ TestContext context, HttpMessage httpMessage, @Nullable OasOperation operation,
+ @Nullable OasResponse response) {
+
+ if (operation == null || response == null) {
+ return;
+ }
+
+ fillRequiredHeaders(
+ openApiSpecification, context, httpMessage, response);
+
+ Optional responseSchema = OasModelHelper.getSchema(response);
+ responseSchema.ifPresent(oasSchema -> {
+ httpMessage.setPayload(
+ OpenApiTestValidationDataGenerator.createInboundPayload(oasSchema,
+ OasModelHelper.getSchemaDefinitions(
+ openApiSpecification.getOpenApiDoc(context)), openApiSpecification));
+
+ OasSchema resolvedSchema = OasModelHelper.resolveSchema(
+ openApiSpecification.getOpenApiDoc(null), oasSchema);
+ if (OasModelHelper.isObjectType(resolvedSchema) || OasModelHelper.isObjectArrayType(
+ resolvedSchema)) {
+ Collection responseTypes = OasModelHelper.getResponseTypes(operation,
+ response);
+ if (responseTypes.contains(MediaType.APPLICATION_JSON_VALUE)) {
+ httpMessage.setHeader(HttpHeaders.CONTENT_TYPE,
+ MediaType.APPLICATION_JSON_VALUE);
+ httpMessage.setType(MessageType.JSON);
+ }
+ }
+ }
+ );
+ }
+
+ private static void fillRequiredHeaders(
+ OpenApiSpecification openApiSpecification, TestContext context, HttpMessage httpMessage,
+ OasResponse response) {
+
+ Map requiredHeaders = OasModelHelper.getRequiredHeaders(response);
+ for (Map.Entry header : requiredHeaders.entrySet()) {
+ httpMessage.setHeader(header.getKey(),
+ OpenApiTestValidationDataGenerator.createValidationExpression(header.getKey(),
+ header.getValue(),
+ OasModelHelper.getSchemaDefinitions(
+ openApiSpecification.getOpenApiDoc(context)), false,
+ openApiSpecification,
+ context));
+ }
+
+ Map headers = OasModelHelper.getHeaders(response);
+ for (Map.Entry header : headers.entrySet()) {
+ if (!requiredHeaders.containsKey(header.getKey()) && context.getVariables()
+ .containsKey(header.getKey())) {
+ httpMessage.setHeader(header.getKey(),
+ CitrusSettings.VARIABLE_PREFIX + header.getKey()
+ + CitrusSettings.VARIABLE_SUFFIX);
+ }
+ }
}
private static class OpenApiClientResponseMessageBuilder extends HttpMessageBuilder {
@@ -64,8 +158,11 @@ private static class OpenApiClientResponseMessageBuilder extends HttpMessageBuil
private final HttpMessage httpMessage;
- public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage, OpenApiSpecification openApiSpec,
- String operationId, String statusCode) {
+ private boolean oasValidationEnabled = true;
+
+ public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage,
+ OpenApiSpecification openApiSpec,
+ String operationId, String statusCode) {
super(httpMessage);
this.openApiSpec = openApiSpec;
this.operationId = operationId;
@@ -75,57 +172,36 @@ public OpenApiClientResponseMessageBuilder(HttpMessage httpMessage, OpenApiSpeci
@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) {
+ 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.responses != null) {
- OasResponse response = Optional.ofNullable(operation.responses.getItem(statusCode))
- .orElse(operation.responses.default_);
+ return super.build(context, messageType);
+ }
- if (response != null) {
- Map requiredHeaders = OasModelHelper.getRequiredHeaders(response);
- for (Map.Entry header : requiredHeaders.entrySet()) {
- httpMessage.setHeader(header.getKey(), OpenApiTestDataGenerator.createValidationExpression(header.getKey(), header.getValue(),
- OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context));
- }
+ private void buildMessageFromOperation(OperationPathAdapter operationPathAdapter, TestContext context) {
+ OasOperation operation = operationPathAdapter.operation();
- Map headers = OasModelHelper.getHeaders(response);
- for (Map.Entry header : headers.entrySet()) {
- if (!requiredHeaders.containsKey(header.getKey()) && context.getVariables().containsKey(header.getKey())) {
- httpMessage.setHeader(header.getKey(), CitrusSettings.VARIABLE_PREFIX + header.getKey() + CitrusSettings.VARIABLE_SUFFIX);
- }
- }
+ if (oasValidationEnabled && operation.responses != null) {
+ Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration(
+ openApiSpec.getOpenApiDoc(context), operation, statusCode, null);
- Optional responseSchema = OasModelHelper.getSchema(response);
- responseSchema.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)));
- }
+ responseForRandomGeneration.ifPresent(
+ oasResponse -> fillMessageFromResponse(openApiSpec, context, httpMessage,
+ operation, oasResponse));
}
- OasModelHelper.getResponseContentType(oasDocument, operation)
- .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType));
-
- if (Pattern.compile("[0-9]+").matcher(statusCode).matches()) {
+ if (Pattern.compile("\\d+").matcher(statusCode).matches()) {
httpMessage.status(HttpStatus.valueOf(Integer.parseInt(statusCode)));
} else {
httpMessage.status(HttpStatus.OK);
}
+ }
- return super.build(context, messageType);
+ public void setOasValidationEnabled(boolean oasValidationEnabled) {
+ this.oasValidationEnabled = oasValidationEnabled;
}
}
}
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 97b1f3ec06..316add2fd3 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerRequestActionBuilder.java
@@ -16,18 +16,23 @@
package org.citrusframework.openapi.actions;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Optional;
-import java.util.regex.Pattern;
+import static java.lang.String.format;
+import static org.citrusframework.message.MessageType.JSON;
+import static org.citrusframework.message.MessageType.PLAINTEXT;
+import static org.citrusframework.message.MessageType.XML;
+import static org.citrusframework.openapi.model.OasModelHelper.getRequestContentType;
+import static org.citrusframework.util.StringUtils.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.Optional;
+import java.util.regex.Pattern;
import org.citrusframework.CitrusSettings;
+import org.citrusframework.actions.ReceiveMessageAction;
import org.citrusframework.context.TestContext;
import org.citrusframework.exceptions.CitrusRuntimeException;
import org.citrusframework.http.actions.HttpServerRequestActionBuilder;
@@ -35,8 +40,10 @@
import org.citrusframework.http.message.HttpMessageBuilder;
import org.citrusframework.message.Message;
import org.citrusframework.openapi.OpenApiSpecification;
-import org.citrusframework.openapi.OpenApiTestDataGenerator;
+import org.citrusframework.openapi.OpenApiTestValidationDataGenerator;
import org.citrusframework.openapi.model.OasModelHelper;
+import org.citrusframework.openapi.model.OperationPathAdapter;
+import org.citrusframework.openapi.validation.OpenApiRequestValidationProcessor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
@@ -46,6 +53,14 @@
*/
public class OpenApiServerRequestActionBuilder extends HttpServerRequestActionBuilder {
+ private OpenApiRequestValidationProcessor openApiRequestValidationProcessor;
+
+ private final OpenApiSpecification openApiSpec;
+
+ private final String operationId;
+
+ private boolean oasValidationEnabled = true;
+
/**
* Default constructor initializes http request message builder.
*/
@@ -53,9 +68,29 @@ 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);
+ this.openApiSpec = openApiSpec;
+ this.operationId = operationId;
+ }
+
+ @Override
+ public ReceiveMessageAction doBuild() {
+
+ if (oasValidationEnabled && !messageProcessors.contains(openApiRequestValidationProcessor)) {
+ openApiRequestValidationProcessor = new OpenApiRequestValidationProcessor(openApiSpec, operationId);
+ validate(openApiRequestValidationProcessor);
+ }
+
+ return super.doBuild();
+ }
+
+ public OpenApiServerRequestActionBuilder disableOasValidation(boolean disable) {
+ oasValidationEnabled = !disable;
+ return this;
}
private static class OpenApiServerRequestMessageBuilder extends HttpMessageBuilder {
@@ -65,8 +100,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;
@@ -75,75 +111,142 @@ public OpenApiServerRequestMessageBuilder(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;
- }
- }
- if (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()));
- }
+ });
+
+ return super.build(context, messageType);
+ }
+
+ 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(OperationPathAdapter operationPathAdapter) {
+ OasModelHelper.getRequestContentType(operationPathAdapter.operation())
+ .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE,
+ String.format("@startsWith(%s)@", contentType)));
+ }
+
+ private void setSpecifiedPath(TestContext context, OperationPathAdapter operationPathAdapter) {
+ String randomizedPath = OasModelHelper.getBasePath(openApiSpec.getOpenApiDoc(context))
+ + operationPathAdapter.apiPath();
+ randomizedPath = randomizedPath.replace("//", "/");
- if (operation.parameters != null) {
- operation.parameters.stream()
- .filter(param -> "header".equals(param.in))
- .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName()))
- .forEach(param -> httpMessage.setHeader(param.getName(),
- OpenApiTestDataGenerator.createValidationExpression(param.getName(), (OasSchema) param.schema,
- OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context)));
-
- operation.parameters.stream()
- .filter(param -> "query".equals(param.in))
- .filter(param -> (param.required != null && param.required) || context.getVariables().containsKey(param.getName()))
- .forEach(param -> httpMessage.queryParam(param.getName(),
- OpenApiTestDataGenerator.createValidationExpression(param.getName(), (OasSchema) param.schema,
- OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context)));
+ randomizedPath = appendSegmentToUrlPath(openApiSpec.getRootContextPath(), randomizedPath);
+
+ if (operationPathAdapter.operation().parameters != null) {
+ randomizedPath = determinePath(context, operationPathAdapter.operation(),
+ randomizedPath);
}
- Optional body = OasModelHelper.getRequestBodySchema(oasDocument, operation);
- body.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createInboundPayload(oasSchema, OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)));
+ httpMessage.path(randomizedPath);
+ }
- String randomizedPath = OasModelHelper.getBasePath(oasDocument) + pathItem.getPath();
- randomizedPath = randomizedPath.replaceAll("//", "/");
+ private void setSpecifiedBody(TestContext context, OperationPathAdapter operationPathAdapter) {
+ Optional body = OasModelHelper.getRequestBodySchema(
+ openApiSpec.getOpenApiDoc(context), operationPathAdapter.operation());
+ body.ifPresent(oasSchema -> httpMessage.setPayload(
+ OpenApiTestValidationDataGenerator.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();
- if (operation.parameters != null) {
- List pathParams = operation.parameters.stream()
- .filter(p -> "path".equals(p.in)).toList();
+ for (OasParameter parameter : pathParams) {
+ String parameterValue;
+ if (context.getVariables().containsKey(parameter.getName())) {
+ parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName()
+ + CitrusSettings.VARIABLE_SUFFIX;
+ randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}")
+ .matcher(randomizedPath)
+ .replaceAll(parameterValue);
+ } else {
+ parameterValue = OpenApiTestValidationDataGenerator.createValidationRegex(
+ parameter.getName(),
+ OasModelHelper.getParameterSchema(parameter).orElse(null));
- for (OasParameter parameter : pathParams) {
- String parameterValue;
- if (context.getVariables().containsKey(parameter.getName())) {
- parameterValue = "\\" + CitrusSettings.VARIABLE_PREFIX + parameter.getName() + CitrusSettings.VARIABLE_SUFFIX;
- } else {
- parameterValue = OpenApiTestDataGenerator.createValidationExpression((OasSchema) parameter.schema,
- OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec);
- }
randomizedPath = Pattern.compile("\\{" + parameter.getName() + "}")
- .matcher(randomizedPath)
- .replaceAll(parameterValue);
+ .matcher(randomizedPath)
+ .replaceAll(parameterValue);
+
+ randomizedPath = format("@matches('%s')@", randomizedPath);
}
}
+ return randomizedPath;
+ }
- OasModelHelper.getRequestContentType(operation)
- .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, String.format("@startsWith(%s)@", contentType)));
+ private void setSpecifiedQueryParameters(TestContext context,
+ OperationPathAdapter operationPathAdapter) {
- httpMessage.path(randomizedPath);
- httpMessage.method(method);
+ if (operationPathAdapter.operation().parameters == null) {
+ return;
+ }
- return super.build(context, messageType);
+ 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(),
+ OpenApiTestValidationDataGenerator.createValidationExpression(param.getName(),
+ OasModelHelper.getParameterSchema(param).orElse(null),
+ OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), false,
+ openApiSpec,
+ context)));
+
+ }
+
+ private void setSpecifiedHeaders(TestContext context,
+ OperationPathAdapter operationPathAdapter) {
+
+ if (operationPathAdapter.operation().parameters == null) {
+ return;
+ }
+
+ operationPathAdapter.operation().parameters.stream()
+ .filter(param -> "header".equals(param.in))
+ .filter(
+ param -> (param.required != null && param.required) || context.getVariables()
+ .containsKey(param.getName()))
+ .forEach(param -> httpMessage.setHeader(param.getName(),
+ OpenApiTestValidationDataGenerator.createValidationExpression(param.getName(),
+ OasModelHelper.getParameterSchema(param).orElse(null),
+ OasModelHelper.getSchemaDefinitions(openApiSpec.getOpenApiDoc(context)), false,
+ openApiSpec,
+ context)));
+ }
+
+ private void setSpecifiedMessageType(OperationPathAdapter operationPathAdapter) {
+ Optional requestContentType = getRequestContentType(
+ operationPathAdapter.operation());
+ if (requestContentType.isPresent() && APPLICATION_JSON_VALUE.equals(
+ requestContentType.get())) {
+ httpMessage.setType(JSON);
+ } else if (requestContentType.isPresent() && APPLICATION_XML_VALUE.equals(
+ requestContentType.get())) {
+ httpMessage.setType(XML);
+ } else {
+ httpMessage.setType(PLAINTEXT);
+ }
+ }
+
+ private void setSpecifiedMethod(OperationPathAdapter operationPathAdapter) {
+ httpMessage.method(HttpMethod.valueOf(operationPathAdapter.operation().getMethod().toUpperCase()));
}
}
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java
index 2c4a67c100..17611ae4ae 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/actions/OpenApiServerResponseActionBuilder.java
@@ -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.actions.SendMessageAction;
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,91 +60,197 @@
*/
public class OpenApiServerResponseActionBuilder extends HttpServerResponseActionBuilder {
+ private OpenApiResponseValidationProcessor openApiResponseValidationProcessor;
+
+ private final OpenApiSpecification openApiSpec;
+
+ private final String operationId;
+
+ private boolean oasValidationEnabled = true;
+
/**
* 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) {
- super(new OpenApiServerResponseMessageBuilder(httpMessage, openApiSpec, operationId, statusCode), httpMessage);
+ public OpenApiServerResponseActionBuilder(HttpMessage httpMessage,
+ OpenApiSpecification openApiSpec,
+ String operationId, String statusCode, String accept) {
+ super(new OpenApiServerResponseMessageBuilder(httpMessage, openApiSpec, operationId,
+ statusCode, accept), httpMessage);
+ this.openApiSpec = openApiSpec;
+ this.operationId = operationId;
+ }
+
+ @Override
+ public SendMessageAction doBuild() {
+
+ if (oasValidationEnabled && !messageProcessors.contains(openApiResponseValidationProcessor)) {
+ openApiResponseValidationProcessor = new OpenApiResponseValidationProcessor(openApiSpec, operationId);
+ process(openApiResponseValidationProcessor);
+ }
+
+ return super.doBuild();
+ }
+
+ public OpenApiServerResponseActionBuilder disableOasValidation(boolean disable) {
+ oasValidationEnabled = !disable;
+ return this;
+ }
+
+ 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 (STATUS_CODE_PATTERN.matcher(statusCode).matches()) {
+ getMessage().status(HttpStatus.valueOf(parseInt(statusCode)));
+ } else {
+ getMessage().status(OK);
+ }
+
+ List initialHeaderBuilders = new ArrayList<>(getHeaderBuilders());
+ getHeaderBuilders().clear();
- if (operationEntry.isPresent()) {
- operation = operationEntry.get().getValue();
- break;
- }
+ 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 == null) {
- throw new CitrusRuntimeException(("Unable to locate operation with id '%s' " +
- "in OpenAPI specification %s").formatted(operationId, openApiSpec.getSpecUrl()));
+ // Initial header builder need to be prepended, so that they can overwrite randomly generated headers.
+ getHeaderBuilders().addAll(initialHeaderBuilders);
+
+ return super.build(context, messageType);
+ }
+
+ private void fillRandomData(OperationPathAdapter operationPathAdapter, TestContext context) {
+
+ if (operationPathAdapter.operation().responses != null) {
+ buildResponse(context, operationPathAdapter.operation());
+ }
+ }
+
+ private void buildResponse(TestContext context, OasOperation operation) {
+
+ Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration(
+ openApiSpec.getOpenApiDoc(context), operation, statusCode, null);
+
+ if (responseForRandomGeneration.isPresent()) {
+ buildRandomHeaders(context, responseForRandomGeneration.get());
+ buildRandomPayload(operation, responseForRandomGeneration.get());
+ }
+ }
+
+ private void buildRandomHeaders(TestContext context, 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(),
+ 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, 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 (operation.responses != null) {
- OasResponse response = Optional.ofNullable(operation.responses.getItem(statusCode))
- .orElse(operation.responses.default_);
-
- if (response != null) {
- Map requiredHeaders = OasModelHelper.getRequiredHeaders(response);
- for (Map.Entry header : requiredHeaders.entrySet()) {
- httpMessage.setHeader(header.getKey(),
- OpenApiTestDataGenerator.createRandomValueExpression(header.getKey(), header.getValue(),
- OasModelHelper.getSchemaDefinitions(oasDocument), false, openApiSpec, context));
- }
-
- Map headers = OasModelHelper.getHeaders(response);
- for (Map.Entry header : headers.entrySet()) {
- if (!requiredHeaders.containsKey(header.getKey()) && context.getVariables().containsKey(header.getKey())) {
- httpMessage.setHeader(header.getKey(), CitrusSettings.VARIABLE_PREFIX + header.getKey() + CitrusSettings.VARIABLE_SUFFIX);
- }
- }
-
- Optional responseSchema = OasModelHelper.getSchema(response);
- responseSchema.ifPresent(oasSchema -> httpMessage.setPayload(OpenApiTestDataGenerator.createOutboundPayload(oasSchema,
- OasModelHelper.getSchemaDefinitions(oasDocument), openApiSpec)));
+ if (schemaForMediaTypeOptional.isPresent()) {
+ OasAdapter schemaForMediaType = schemaForMediaTypeOptional.get();
+ if (getMessage().getPayload() == null || (
+ getMessage().getPayload() instanceof String string && string.isEmpty())) {
+ createRandomPayload(getMessage(), schemaForMediaType);
+ }
+
+ // If we have a schema and a media type and the content type has not yet been set, do it.
+ // If schema is null, we do not set the content type, as there is no content.
+ if (!getMessage().getHeaders().containsKey(HttpMessageHeaders.HTTP_CONTENT_TYPE) && schemaForMediaType.adapted() != null && schemaForMediaType.node() != null) {
+ addHeaderBuilder(new DefaultHeaderBuilder(singletonMap(HttpMessageHeaders.HTTP_CONTENT_TYPE, schemaForMediaType.adapted())));
}
}
+ }
- OasModelHelper.getResponseContentType(oasDocument, operation)
- .ifPresent(contentType -> httpMessage.setHeader(HttpHeaders.CONTENT_TYPE, contentType));
+ private void createRandomPayload(HttpMessage message, OasAdapter schemaForMediaType) {
- if (Pattern.compile("[0-9]+").matcher(statusCode).matches()) {
- httpMessage.status(HttpStatus.valueOf(Integer.parseInt(statusCode)));
+ if (schemaForMediaType.node() == null) {
+ // No schema means no payload, no type
+ message.setPayload(null);
} else {
- httpMessage.status(HttpStatus.OK);
+ if (TEXT_PLAIN_VALUE.equals(schemaForMediaType.adapted())) {
+ // Schema but plain text
+ message.setPayload(createOutboundPayload(schemaForMediaType.node(), openApiSpec));
+ message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, TEXT_PLAIN_VALUE);
+ } else if (APPLICATION_JSON_VALUE.equals(schemaForMediaType.adapted())) {
+ // Json Schema
+ message.setPayload(createOutboundPayload(schemaForMediaType.node(), openApiSpec));
+ message.setHeader(HttpMessageHeaders.HTTP_CONTENT_TYPE, APPLICATION_JSON_VALUE);
+ }
}
-
- return super.build(context, messageType);
}
}
+
}
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..9933ecebc8
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasAdapter.java
@@ -0,0 +1,7 @@
+package org.citrusframework.openapi.model;
+
+import io.apicurio.datamodels.core.models.Node;
+
+public record OasAdapter(S node, T adapted) {
+
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java
index e74259c828..f703be9b92 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasModelHelper.java
@@ -16,34 +16,60 @@
package org.citrusframework.openapi.model;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.function.BiFunction;
-import java.util.function.Function;
+import static java.util.Collections.singletonList;
+import static org.citrusframework.openapi.OpenApiConstants.TYPE_ARRAY;
+import static org.citrusframework.openapi.OpenApiConstants.TYPE_OBJECT;
+import io.apicurio.datamodels.combined.visitors.CombinedVisitorAdapter;
import io.apicurio.datamodels.openapi.models.OasDocument;
import io.apicurio.datamodels.openapi.models.OasOperation;
+import io.apicurio.datamodels.openapi.models.OasParameter;
import io.apicurio.datamodels.openapi.models.OasPathItem;
import io.apicurio.datamodels.openapi.models.OasPaths;
import io.apicurio.datamodels.openapi.models.OasResponse;
+import io.apicurio.datamodels.openapi.models.OasResponses;
import io.apicurio.datamodels.openapi.models.OasSchema;
import io.apicurio.datamodels.openapi.v2.models.Oas20Document;
import io.apicurio.datamodels.openapi.v2.models.Oas20Operation;
+import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter;
import io.apicurio.datamodels.openapi.v2.models.Oas20Response;
+import io.apicurio.datamodels.openapi.v2.models.Oas20Schema;
import io.apicurio.datamodels.openapi.v3.models.Oas30Document;
import io.apicurio.datamodels.openapi.v3.models.Oas30Operation;
+import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter;
import io.apicurio.datamodels.openapi.v3.models.Oas30Response;
+import io.apicurio.datamodels.openapi.v3.models.Oas30Schema;
+import jakarta.annotation.Nullable;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Predicate;
import org.citrusframework.openapi.model.v2.Oas20ModelHelper;
import org.citrusframework.openapi.model.v3.Oas30ModelHelper;
+import org.citrusframework.util.StringUtils;
+import org.springframework.http.MediaType;
/**
* @author Christoph Deppisch
*/
public final class OasModelHelper {
+ public static final String DEFAULT = "default_";
+
+ /**
+ * List of preferred media types in the order of priority,
+ * used when no specific 'Accept' header is provided to determine the default response type.
+ */
+ public static final List DEFAULT_ACCEPTED_MEDIA_TYPES = List.of(MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_PLAIN_VALUE);
+
private OasModelHelper() {
// utility class
}
@@ -53,8 +79,8 @@ private OasModelHelper() {
* @param schema to check
* @return true if given schema is an object.
*/
- public static boolean isObjectType(OasSchema schema) {
- return "object".equals(schema.type);
+ public static boolean isObjectType(@Nullable OasSchema schema) {
+ return schema != null && TYPE_OBJECT.equals(schema.type);
}
/**
@@ -62,8 +88,29 @@ public static boolean isObjectType(OasSchema schema) {
* @param schema to check
* @return true if given schema is an array.
*/
- public static boolean isArrayType(OasSchema schema) {
- return "array".equals(schema.type);
+ public static boolean isArrayType(@Nullable OasSchema schema) {
+ return schema != null && TYPE_ARRAY.equals(schema.type);
+ }
+
+ /**
+ * Determines if given schema is of type object array .
+ * @param schema to check
+ * @return true if given schema is an object array.
+ */
+ public static boolean isObjectArrayType(@Nullable OasSchema schema) {
+
+ if (schema == null || !TYPE_ARRAY.equals(schema.type)) {
+ return false;
+ }
+
+ Object items = schema.items;
+ if (items instanceof OasSchema oasSchema) {
+ return isObjectType(oasSchema);
+ } else if (items instanceof List> list) {
+ return list.stream().allMatch(item -> item instanceof OasSchema oasSchema && isObjectType(oasSchema));
+ }
+
+ return false;
}
/**
@@ -71,8 +118,12 @@ public static boolean isArrayType(OasSchema schema) {
* @param schema to check
* @return true if given schema has a reference.
*/
- public static boolean isReferenceType(OasSchema schema) {
- return schema.$ref != null;
+ public static boolean isReferenceType(@Nullable OasSchema schema) {
+ return schema != null && schema.$ref != null;
+ }
+
+ public static boolean isCompositeSchema(OasSchema schema) {
+ return delegate(schema, Oas20ModelHelper::isCompositeSchema, Oas30ModelHelper::isCompositeSchema);
}
public static String getHost(OasDocument openApiDoc) {
@@ -83,6 +134,14 @@ public static List getSchemes(OasDocument openApiDoc) {
return delegate(openApiDoc, Oas20ModelHelper::getSchemes, Oas30ModelHelper::getSchemes);
}
+ public static OasSchema resolveSchema(OasDocument oasDocument, OasSchema schema) {
+ if (isReferenceType(schema)) {
+ return getSchemaDefinitions(oasDocument).get(getReferenceName(schema.$ref));
+ }
+
+ return schema;
+ }
+
public static String getBasePath(OasDocument openApiDoc) {
return delegate(openApiDoc, Oas20ModelHelper::getBasePath, Oas30ModelHelper::getBasePath);
}
@@ -163,6 +222,19 @@ 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);
+ }
+
public static Map getRequiredHeaders(OasResponse response) {
return delegate(response, Oas20ModelHelper::getHeaders, Oas30ModelHelper::getRequiredHeaders);
}
@@ -179,8 +251,77 @@ public static Optional getRequestBodySchema(OasDocument openApiDoc, O
return delegate(openApiDoc, operation, Oas20ModelHelper::getRequestBodySchema, Oas30ModelHelper::getRequestBodySchema);
}
- public static Optional getResponseContentType(OasDocument openApiDoc, OasOperation operation) {
- return delegate(openApiDoc, operation, Oas20ModelHelper::getResponseContentType, Oas30ModelHelper::getResponseContentType);
+ public static Collection getResponseTypes(OasOperation operation, OasResponse response) {
+ return delegate(operation, response, Oas20ModelHelper::getResponseTypes, Oas30ModelHelper::getResponseTypes);
+ }
+
+ /**
+ * Determines the appropriate random response from an OpenAPI Specification operation based on the given status code.
+ * If a status code is specified, return the response for the specified status code. May be empty.
+ *
+ * If no exact match is found:
+ *
+ *
Fallback 1: Returns the 'default_' response if it exists.
+ *
Fallback 2: Returns the first response object related to a 2xx status code that contains an acceptable schema for random message generation.
+ *
Fallback 3: Returns the first response object related to a 2xx status code even without a schema. This is for operations that simply do not return anything else than a status code.
+ *
Fallback 4: Returns the first response in the list of responses, no matter which schema.
+ *
+ *
+ * Note that for Fallback 3 and 4, it is very likely, that there is no schema specified. It is expected, that an empty response is a viable response in these cases.
+ *
+ * @param openApiDoc The OpenAPI document containing the API specifications.
+ * @param operation The OAS operation for which to determine the response.
+ * @param statusCode The specific status code to match against responses, or {@code null} to search for any acceptable response.
+ * @param accept The mediatype accepted by the request
+ * @return An {@link Optional} containing the resolved {@link OasResponse} if found, or {@link Optional#empty()} otherwise.
+ */
+ public static Optional getResponseForRandomGeneration(OasDocument openApiDoc, OasOperation operation, @Nullable String statusCode, @Nullable String accept) {
+
+ if (operation.responses == null || operation.responses.getResponses().isEmpty()) {
+ return Optional.empty();
+ }
+
+ // Resolve all references
+ Map responseMap = OasModelHelper.resolveResponses(openApiDoc,
+ operation.responses);
+
+ // For a given status code, do not fall back
+ if (statusCode != null) {
+ return Optional.ofNullable(responseMap.get(statusCode));
+ }
+
+ // Only accept responses that provide a schema for which we can actually provide a random message
+ Predicate acceptedSchemas = resp -> getSchema(operation, resp, accept != null ? singletonList(accept) : DEFAULT_ACCEPTED_MEDIA_TYPES).isPresent();
+
+ // Fallback 1: Pick the default if it exists
+ Optional response = Optional.ofNullable(responseMap.get(DEFAULT));
+
+ if (response.isEmpty()) {
+ // Fallback 2: Pick the response object related to the first 2xx, providing an accepted schema
+ response = responseMap.values().stream()
+ .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2"))
+ .map(OasResponse.class::cast)
+ .filter(acceptedSchemas)
+ .findFirst();
+ }
+
+ if (response.isEmpty()) {
+ // Fallback 3: Pick the response object related to the first 2xx (even without schema)
+ response = responseMap.values().stream()
+ .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2"))
+ .map(OasResponse.class::cast)
+ .findFirst();
+ }
+
+ if (response.isEmpty()) {
+ // Fallback 4: Pick the first response no matter which schema
+ response = operation.responses.getResponses().stream()
+ .map(resp -> responseMap.get(resp.getStatusCode()))
+ .filter(Objects::nonNull)
+ .findFirst();
+ }
+
+ return response;
}
/**
@@ -210,15 +351,69 @@ private static T delegate(OasDocument openApiDoc, Function
* @return
*/
private static T delegate(OasResponse response, Function oas20Function, Function oas30Function) {
- if (response instanceof Oas20Response) {
- return oas20Function.apply((Oas20Response) response);
- } else if (response instanceof Oas30Response) {
- return oas30Function.apply((Oas30Response) response);
+ if (response instanceof Oas20Response oas20Response) {
+ return oas20Function.apply(oas20Response);
+ } else if (response instanceof Oas30Response oas30Response) {
+ return oas30Function.apply(oas30Response);
}
throw new IllegalArgumentException(String.format("Unsupported operation response type: %s", response.getClass()));
}
+ /**
+ * Delegate method to version specific model helpers for Open API v2 or v3.
+ * @param response
+ * @param oas20Function function to apply in case of v2
+ * @param oas30Function function to apply in case of v3
+ * @param generic return value
+ * @return
+ */
+ private static T delegate(OasOperation operation, OasResponse response, BiFunction oas20Function, BiFunction oas30Function) {
+ if (operation instanceof Oas20Operation oas20Operation && response instanceof Oas20Response oas20Response) {
+ return oas20Function.apply(oas20Operation, oas20Response);
+ } else if (operation instanceof Oas30Operation oas30Operation && response instanceof Oas30Response oas30Response) {
+ return oas30Function.apply(oas30Operation, oas30Response);
+ }
+
+ throw new IllegalArgumentException(String.format("Unsupported operation response type: %s", response.getClass()));
+ }
+
+ /**
+ * Delegate method to version specific model helpers for Open API v2 or v3.
+ * @param parameter
+ * @param oas20Function function to apply in case of v2
+ * @param oas30Function function to apply in case of v3
+ * @param generic return value
+ * @return
+ */
+ private static T delegate(OasParameter parameter, Function oas20Function, Function oas30Function) {
+ if (parameter instanceof Oas20Parameter oas20Parameter) {
+ return oas20Function.apply(oas20Parameter);
+ } else if (parameter instanceof Oas30Parameter oas30Parameter) {
+ return oas30Function.apply(oas30Parameter);
+ }
+
+ throw new IllegalArgumentException(String.format("Unsupported operation parameter type: %s", parameter.getClass()));
+ }
+
+ /**
+ * Delegate method to version specific model helpers for Open API v2 or v3.
+ * @param schema
+ * @param oas20Function function to apply in case of v2
+ * @param oas30Function function to apply in case of v3
+ * @param generic return value
+ * @return
+ */
+ private static T delegate(OasSchema schema, Function oas20Function, Function oas30Function) {
+ if (schema instanceof Oas20Schema oas20Schema) {
+ return oas20Function.apply(oas20Schema);
+ } else if (schema instanceof Oas30Schema oas30Schema) {
+ return oas30Function.apply(oas30Schema);
+ }
+
+ throw new IllegalArgumentException(String.format("Unsupported operation parameter type: %s", schema.getClass()));
+ }
+
/**
* Delegate method to version specific model helpers for Open API v2 or v3.
* @param operation
@@ -228,10 +423,10 @@ private static T delegate(OasResponse response, Function o
* @return
*/
private static T delegate(OasOperation operation, Function oas20Function, Function oas30Function) {
- if (operation instanceof Oas20Operation) {
- return oas20Function.apply((Oas20Operation) operation);
- } else if (operation instanceof Oas30Operation) {
- return oas30Function.apply((Oas30Operation) operation);
+ if (operation instanceof Oas20Operation oas20Operation) {
+ return oas20Function.apply(oas20Operation);
+ } else if (operation instanceof Oas30Operation oas30Operation) {
+ return oas30Function.apply(oas30Operation);
}
throw new IllegalArgumentException(String.format("Unsupported operation type: %s", operation.getClass()));
@@ -239,6 +434,8 @@ private static T delegate(OasOperation operation, Function
+ * This method iterates over the responses contained in the {@link OasResponses} object. If a response has a reference
+ * (indicated by a non-null {@code $ref} field), 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.
+ * @return a {@link List} of {@link OasResponse} instances, where all references have been resolved.
+ */
+ private static Map resolveResponses(OasDocument openApiDoc, OasResponses responses) {
+
+ Function responseResolver = getResponseResolver(
+ openApiDoc);
+
+ Map responseMap = new HashMap<>();
+ for (OasResponse response : responses.getResponses()) {
+ if (response.$ref != null) {
+ OasResponse resolved = responseResolver.apply(getReferenceName(response.$ref));
+ if (resolved != null) {
+ // 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 {
+ responseMap.put(DEFAULT, responses.default_);
+ }
+ }
+
+ return responseMap;
+ }
+
+ private static Function getResponseResolver(
+ OasDocument openApiDoc) {
+ return delegate(openApiDoc,
+ doc -> (responseRef -> doc.responses.getResponse(OasModelHelper.getReferenceName(responseRef))),
+ doc -> (responseRef -> doc.components.responses.get(OasModelHelper.getReferenceName(responseRef))));
+ }
+
+ /**
+ * Traverses the OAS document and applies the given visitor to each OAS operation found.
+ * This method uses the provided {@link OasOperationVisitor} to process each operation within the paths of the OAS document.
+ *
+ * @param oasDocument the OAS document to traverse
+ * @param visitor the visitor to apply to each OAS operation
+ */
+ public static void visitOasOperations(OasDocument oasDocument, OasOperationVisitor visitor) {
+ if (oasDocument == null || visitor == null) {
+ return;
+ }
+
+ oasDocument.paths.accept(new CombinedVisitorAdapter() {
+
+ @Override
+ public void visitPaths(OasPaths oasPaths) {
+ oasPaths.getPathItems().forEach(oasPathItem -> oasPathItem.accept(this));
+ }
+
+ @Override
+ public void visitPathItem(OasPathItem oasPathItem) {
+ String path = oasPathItem.getPath();
+
+ if (StringUtils.isEmpty(path)) {
+ return;
+ }
+
+ getOperationMap(oasPathItem).values()
+ .forEach(oasOperation -> visitor.visit(oasPathItem, oasOperation));
+
+ }
+ });
+ }
+
+ /**
+ * Resolves and normalizes a list of accepted media types. If the input list is null,
+ * returns null. Otherwise, splits each media type string by comma, trims whitespace,
+ * and collects them into a list of normalized types.
+ *
+ * @param acceptedMediaTypes List of accepted media types, may be null.
+ * @return Normalized list of media types, or null if input is null.
+ */
+ public static List resolveAllTypes(@Nullable List acceptedMediaTypes) {
+ if (acceptedMediaTypes == null) {
+ return acceptedMediaTypes;
+ }
+
+ return acceptedMediaTypes.stream()
+ .flatMap(types -> Arrays.stream(types.split(","))).map(String::trim).toList();
+ }
+
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java
new file mode 100644
index 0000000000..85e4cfbb35
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OasOperationVisitor.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi.model;
+
+import io.apicurio.datamodels.openapi.models.OasOperation;
+import io.apicurio.datamodels.openapi.models.OasPathItem;
+
+/**
+ * The {@code OasOperationVisitor} interface defines a visitor pattern for operations on OAS (OpenAPI Specification) path items and operations.
+ */
+public interface OasOperationVisitor {
+
+ void visit(OasPathItem oasPathItem, OasOperation oasOperation);
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java
index 13e4a74008..f9b9727b4c 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OpenApiVersion.java
@@ -16,12 +16,12 @@
package org.citrusframework.openapi.model;
-import java.util.Arrays;
-
import io.apicurio.datamodels.openapi.models.OasDocument;
import io.apicurio.datamodels.openapi.v2.models.Oas20Document;
import io.apicurio.datamodels.openapi.v3.models.Oas30Document;
+import java.util.Arrays;
+
/**
* List of supported OpenAPI specification versions and their corresponding model document types.
*/
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/OperationPathAdapter.java
new file mode 100644
index 0000000000..c1a1999ac9
--- /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 io.apicurio.datamodels.openapi.models.OasOperation;
+import org.citrusframework.openapi.util.OpenApiUtils;
+
+import static java.lang.String.format;
+
+/**
+ * Adapts the different paths associated with an OpenAPI operation to the {@link OasOperation}.
+ * This record holds the API path, context path, full path, and the associated {@link OasOperation} object.
+ *
+ * @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 36d9596fd4..cb00a6ef46 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v2/Oas20ModelHelper.java
@@ -16,21 +16,27 @@
package org.citrusframework.openapi.model.v2;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.stream.Collectors;
-
import io.apicurio.datamodels.openapi.models.OasHeader;
import io.apicurio.datamodels.openapi.models.OasParameter;
import io.apicurio.datamodels.openapi.models.OasSchema;
import io.apicurio.datamodels.openapi.v2.models.Oas20Document;
import io.apicurio.datamodels.openapi.v2.models.Oas20Header;
import io.apicurio.datamodels.openapi.v2.models.Oas20Operation;
+import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter;
import io.apicurio.datamodels.openapi.v2.models.Oas20Response;
import io.apicurio.datamodels.openapi.v2.models.Oas20Schema;
+import io.apicurio.datamodels.openapi.v2.models.Oas20Schema.Oas20AllOfSchema;
import io.apicurio.datamodels.openapi.v2.models.Oas20SchemaDefinition;
+import jakarta.annotation.Nullable;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.citrusframework.openapi.model.OasAdapter;
+import org.citrusframework.openapi.model.OasModelHelper;
/**
* @author Christoph Deppisch
@@ -67,7 +73,29 @@ public static Optional getSchema(Oas20Response response) {
return Optional.ofNullable(response.schema);
}
- public static Optional getRequestBodySchema(Oas20Document openApiDoc, Oas20Operation operation) {
+ public static Optional> getSchema(Oas20Operation oas20Operation, Oas20Response response, List acceptedMediaTypes) {
+
+ acceptedMediaTypes = OasModelHelper.resolveAllTypes(acceptedMediaTypes);
+ acceptedMediaTypes = acceptedMediaTypes != null ? acceptedMediaTypes : OasModelHelper.DEFAULT_ACCEPTED_MEDIA_TYPES;
+
+ OasSchema selectedSchema = response.schema;
+ String selectedMediaType = null;
+ if (oas20Operation.produces != null && !oas20Operation.produces.isEmpty()) {
+ selectedMediaType = acceptedMediaTypes.stream()
+ .filter(type -> !isFormDataMediaType(type))
+ .filter(type -> oas20Operation.produces.contains(type)).findFirst()
+ .orElse(null);
+ }
+
+ return selectedSchema == null && selectedMediaType == null ? Optional.empty() : Optional.of(new OasAdapter<>(selectedSchema, selectedMediaType));
+ }
+
+ public static boolean isCompositeSchema(Oas20Schema schema) {
+ // Note that oneOf and anyOf is not supported by Oas20.
+ return schema instanceof Oas20AllOfSchema;
+ }
+
+ public static Optional getRequestBodySchema(@Nullable Oas20Document ignoredOpenApiDoc, Oas20Operation operation) {
if (operation.parameters == null) {
return Optional.empty();
}
@@ -89,12 +117,11 @@ public static Optional getRequestContentType(Oas20Operation operation) {
return Optional.empty();
}
- public static Optional getResponseContentType(Oas20Document openApiDoc, Oas20Operation operation) {
- if (operation.produces != null) {
- return Optional.of(operation.produces.get(0));
+ public static Collection getResponseTypes(Oas20Operation operation, @Nullable Oas20Response ignoredResponse) {
+ if (operation == null) {
+ return Collections.emptyList();
}
-
- return Optional.empty();
+ return operation.produces;
}
public static Map getHeaders(Oas20Response response) {
@@ -106,6 +133,17 @@ public static Map getHeaders(Oas20Response response) {
.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);
+ }
+
+ /**
+ * If the header already contains a schema (and it is an instance of {@link Oas20Header}), this schema is returned.
+ * Otherwise, a new {@link Oas20Header} is created based on the properties of the parameter and returned.
+ *
+ * @param header the {@link Oas20Header} from which to extract or create the schema
+ * @return an {@link Optional} containing the extracted or newly created {@link OasSchema}
+ */
private static OasSchema getHeaderSchema(Oas20Header header) {
Oas20Schema schema = new Oas20Schema();
schema.title = header.getName();
@@ -132,4 +170,44 @@ private static OasSchema getHeaderSchema(Oas20Header header) {
schema.exclusiveMinimum = header.exclusiveMinimum;
return schema;
}
+
+ /**
+ * If the parameter already contains a schema (and it is an instance of {@link Oas20Schema}), this schema is returned.
+ * Otherwise, a new {@link Oas20Schema} is created based on the properties of the parameter and returned.
+ *
+ * @param parameter the {@link Oas20Parameter} from which to extract or create the schema
+ * @return an {@link Optional} containing the extracted or newly created {@link OasSchema}
+ */
+ public static Optional getParameterSchema(Oas20Parameter parameter) {
+ if (parameter.schema instanceof Oas20Schema oasSchema) {
+ return Optional.of(oasSchema);
+ }
+
+ Oas20Schema schema = new Oas20Schema();
+ schema.title = parameter.getName();
+ schema.type = parameter.type;
+ schema.format = parameter.format;
+ schema.items = parameter.items;
+ schema.multipleOf = parameter.multipleOf;
+
+ schema.default_ = parameter.default_;
+ schema.enum_ = parameter.enum_;
+
+ schema.pattern = parameter.pattern;
+ schema.description = parameter.description;
+ schema.uniqueItems = parameter.uniqueItems;
+
+ schema.maximum = parameter.maximum;
+ schema.maxItems = parameter.maxItems;
+ schema.maxLength = parameter.maxLength;
+ schema.exclusiveMaximum = parameter.exclusiveMaximum;
+
+ schema.minimum = parameter.minimum;
+ schema.minItems = parameter.minItems;
+ schema.minLength = parameter.minLength;
+ schema.exclusiveMinimum = parameter.exclusiveMinimum;
+
+ return Optional.of(schema);
+ }
+
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java
index 39bdd2486e..e21e242705 100644
--- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/model/v3/Oas30ModelHelper.java
@@ -16,30 +16,33 @@
package org.citrusframework.openapi.model.v3;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.stream.Collectors;
-
import io.apicurio.datamodels.core.models.common.Server;
import io.apicurio.datamodels.core.models.common.ServerVariable;
-import io.apicurio.datamodels.openapi.models.OasResponse;
import io.apicurio.datamodels.openapi.models.OasSchema;
import io.apicurio.datamodels.openapi.v3.models.Oas30Document;
import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType;
import io.apicurio.datamodels.openapi.v3.models.Oas30Operation;
+import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter;
import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody;
import io.apicurio.datamodels.openapi.v3.models.Oas30Response;
+import io.apicurio.datamodels.openapi.v3.models.Oas30Schema;
+import org.citrusframework.openapi.model.OasAdapter;
import org.citrusframework.openapi.model.OasModelHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
/**
* @author Christoph Deppisch
*/
@@ -47,6 +50,7 @@ public final class Oas30ModelHelper {
/** Logger */
private static final Logger LOG = LoggerFactory.getLogger(Oas30ModelHelper.class);
+ public static final String NO_URL_ERROR_MESSAGE = "Unable to determine base path from server URL: %s";
private Oas30ModelHelper() {
// utility class
@@ -59,11 +63,7 @@ public static String getHost(Oas30Document openApiDoc) {
String serverUrl = resolveUrl(openApiDoc.servers.get(0));
if (serverUrl.startsWith("http")) {
- try {
- return new URL(serverUrl).getHost();
- } catch (MalformedURLException e) {
- throw new IllegalStateException(String.format("Unable to determine base path from server URL: %s", serverUrl));
- }
+ return URI.create(serverUrl).getHost();
}
return "localhost";
@@ -78,14 +78,18 @@ public static List getSchemes(Oas30Document openApiDoc) {
.map(Oas30ModelHelper::resolveUrl)
.map(serverUrl -> {
try {
- return new URL(serverUrl).getProtocol();
+ return URI.create(serverUrl).toURL().getProtocol();
} catch (MalformedURLException e) {
- LOG.warn(String.format("Unable to determine base path from server URL: %s", serverUrl));
+ LOG.warn(String.format(NO_URL_ERROR_MESSAGE, serverUrl));
return null;
}
})
.filter(Objects::nonNull)
- .collect(Collectors.toList());
+ .toList();
+ }
+
+ public static boolean isCompositeSchema(Oas30Schema schema) {
+ return schema.anyOf != null || schema.oneOf != null || schema.allOf != null;
}
public static String getBasePath(Oas30Document openApiDoc) {
@@ -98,11 +102,7 @@ public static String getBasePath(Oas30Document openApiDoc) {
String serverUrl = resolveUrl(server);
if (serverUrl.startsWith("http")) {
- try {
- basePath = new URL(serverUrl).getPath();
- } catch (MalformedURLException e) {
- throw new IllegalStateException(String.format("Unable to determine base path from server URL: %s", serverUrl));
- }
+ basePath = URI.create(serverUrl).getPath();
} else {
basePath = serverUrl;
}
@@ -117,7 +117,7 @@ public static Map getSchemaDefinitions(Oas30Document openApiD
return openApiDoc.components.schemas.entrySet()
.stream()
- .collect(Collectors.toMap(Map.Entry::getKey, entry -> (OasSchema) entry.getValue()));
+ .collect(Collectors.toMap(Map.Entry::getKey, Entry::getValue));
}
public static Optional getSchema(Oas30Response response) {
@@ -134,6 +134,33 @@ public static Optional getSchema(Oas30Response response) {
.findFirst();
}
+ public static Optional> getSchema(
+ Oas30Operation ignoredOas30Operation, Oas30Response response, List acceptedMediaTypes) {
+
+ acceptedMediaTypes = OasModelHelper.resolveAllTypes(acceptedMediaTypes);
+ acceptedMediaTypes = acceptedMediaTypes != null ? acceptedMediaTypes : OasModelHelper.DEFAULT_ACCEPTED_MEDIA_TYPES;
+
+ Map content = response.content;
+ if (content == null) {
+ return Optional.empty();
+ }
+
+ String selectedMediaType = null;
+ Oas30Schema selectedSchema = null;
+ for (String type : acceptedMediaTypes) {
+ if (!isFormDataMediaType(type)) {
+ Oas30MediaType oas30MediaType = content.get(type);
+ if (oas30MediaType != null) {
+ selectedMediaType = type;
+ selectedSchema = oas30MediaType.schema;
+ break;
+ }
+ }
+ }
+
+ return selectedSchema == null && selectedMediaType == null ? Optional.empty() : Optional.of(new OasAdapter<>(selectedSchema, selectedMediaType));
+ }
+
public static Optional getRequestBodySchema(Oas30Document openApiDoc, Oas30Operation operation) {
if (operation.requestBody == null) {
return Optional.empty();
@@ -172,44 +199,11 @@ public static Optional getRequestContentType(Oas30Operation operation) {
.findFirst();
}
- public static Optional getResponseContentType(Oas30Document openApiDoc, Oas30Operation operation) {
- if (operation.responses == null) {
- return Optional.empty();
- }
-
- List responses = new ArrayList<>();
-
- for (OasResponse response : operation.responses.getResponses()) {
- if (response.$ref != null) {
- responses.add(openApiDoc.components.responses.get(OasModelHelper.getReferenceName(response.$ref)));
- } else {
- responses.add(response);
- }
+ public static Collection getResponseTypes(Oas30Operation operation, Oas30Response response) {
+ if (operation == null) {
+ return Collections.emptySet();
}
-
- // Pick the response object related to the first 2xx return code found
- Optional response = responses.stream()
- .filter(Oas30Response.class::isInstance)
- .filter(r -> r.getStatusCode() != null && r.getStatusCode().startsWith("2"))
- .map(Oas30Response.class::cast)
- .filter(res -> Oas30ModelHelper.getSchema(res).isPresent())
- .findFirst();
-
- // No 2xx response given so pick the first one no matter what status code
- if (!response.isPresent()) {
- response = responses.stream()
- .filter(Oas30Response.class::isInstance)
- .map(Oas30Response.class::cast)
- .filter(res -> Oas30ModelHelper.getSchema(res).isPresent())
- .findFirst();
- }
-
- return response.flatMap(res -> res.content.entrySet()
- .stream()
- .filter(entry -> entry.getValue().schema != null)
- .map(Map.Entry::getKey)
- .findFirst());
-
+ return response.content != null ? response.content.keySet() : Collections.emptyList();
}
public static Map getRequiredHeaders(Oas30Response response) {
@@ -254,4 +248,9 @@ private static String resolveUrl(Server server) {
return url;
}
+
+ public static Optional getParameterSchema(Oas30Parameter parameter) {
+ return Optional.ofNullable((OasSchema) parameter.schema);
+ }
+
}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java
new file mode 100644
index 0000000000..afee6f3f68
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomArrayGenerator.java
@@ -0,0 +1,51 @@
+package org.citrusframework.openapi.random;
+
+import io.apicurio.datamodels.openapi.models.OasSchema;
+import java.util.concurrent.ThreadLocalRandom;
+import org.citrusframework.openapi.model.OasModelHelper;
+
+/**
+ * A generator for producing random arrays based on an OpenAPI schema. This class extends the
+ * {@link RandomGenerator} and provides a specific implementation for generating random arrays
+ * with constraints defined in the schema.
+ *
+ *
The generator supports arrays with items of a single schema type. If the array's items have
+ * different schemas, an {@link UnsupportedOperationException} will be thrown.
s
+ *
+ */
+public class RandomArrayGenerator extends RandomGenerator {
+
+ @Override
+ public boolean handles(OasSchema other) {
+ return OasModelHelper.isArrayType(other);
+ }
+
+ @Override
+ void generate(RandomContext randomContext, OasSchema schema) {
+ Object items = schema.items;
+
+ if (items instanceof OasSchema itemsSchema) {
+ createRandomArrayValueWithSchemaItem(randomContext, schema, itemsSchema);
+ } else {
+ throw new UnsupportedOperationException(
+ "Random array creation for an array with items having different schema is currently not supported!");
+ }
+ }
+
+ private static void createRandomArrayValueWithSchemaItem(RandomContext randomContext,
+ OasSchema schema,
+ OasSchema itemsSchema) {
+
+ Number minItems = schema.minItems != null ? schema.minItems : 1;
+ Number maxItems = schema.maxItems != null ? schema.maxItems : 10;
+
+ int nItems = ThreadLocalRandom.current()
+ .nextInt(minItems.intValue(), maxItems.intValue() + 1);
+
+ randomContext.getRandomModelBuilder().array(() -> {
+ for (int i = 0; i < nItems; i++) {
+ randomContext.generate(itemsSchema);
+ }
+ });
+ }
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java
new file mode 100644
index 0000000000..6a7877ca39
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomCompositeGenerator.java
@@ -0,0 +1,74 @@
+package org.citrusframework.openapi.random;
+
+import static org.springframework.util.CollectionUtils.isEmpty;
+
+import io.apicurio.datamodels.openapi.models.OasSchema;
+import io.apicurio.datamodels.openapi.v3.models.Oas30Schema;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import org.citrusframework.openapi.model.OasModelHelper;
+
+/**
+ * A generator for producing random composite schemas based on an OpenAPI schema. This class extends
+ * the {@link RandomGenerator} and provides a specific implementation for generating composite schemas
+ * with constraints defined in the schema.
+ *
+ *
The generator supports composite schemas, which include `allOf`, `anyOf`, and `oneOf` constructs.
+ */
+public class RandomCompositeGenerator extends RandomGenerator {
+
+ @Override
+ public boolean handles(OasSchema other) {
+ return OasModelHelper.isCompositeSchema(other);
+ }
+
+ @Override
+ void generate(RandomContext randomContext, OasSchema schema) {
+
+ if (!isEmpty(schema.allOf)) {
+ createAllOff(randomContext, schema);
+ } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.anyOf)) {
+ createAnyOf(randomContext, oas30Schema);
+ } else if (schema instanceof Oas30Schema oas30Schema && !isEmpty(oas30Schema.oneOf)) {
+ createOneOf(randomContext, oas30Schema.oneOf);
+ }
+ }
+
+ private static void createOneOf(RandomContext randomContext, List schemas) {
+ int schemaIndex = ThreadLocalRandom.current().nextInt(schemas.size());
+ randomContext.getRandomModelBuilder().object(() ->
+ randomContext.generate(schemas.get(schemaIndex)));
+ }
+
+ private static void createAnyOf(RandomContext randomContext, Oas30Schema schema) {
+
+ randomContext.getRandomModelBuilder().object(() -> {
+ boolean anyAdded = false;
+ for (OasSchema oneSchema : schema.anyOf) {
+ if (ThreadLocalRandom.current().nextBoolean()) {
+ randomContext.generate(oneSchema);
+ anyAdded = true;
+ }
+ }
+
+ // Add at least one
+ if (!anyAdded) {
+ createOneOf(randomContext, schema.anyOf);
+ }
+ });
+ }
+
+ private static Map createAllOff(RandomContext randomContext, OasSchema schema) {
+ Map allOf = new HashMap<>();
+
+ randomContext.getRandomModelBuilder().object(() -> {
+ for (OasSchema oneSchema : schema.allOf) {
+ randomContext.generate(oneSchema);
+ }
+ });
+
+ return allOf;
+ }
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java
new file mode 100644
index 0000000000..c986a16eb3
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomConfiguration.java
@@ -0,0 +1,63 @@
+package org.citrusframework.openapi.random;
+
+import static org.citrusframework.openapi.OpenApiConstants.FORMAT_DATE;
+import static org.citrusframework.openapi.OpenApiConstants.FORMAT_DATE_TIME;
+import static org.citrusframework.openapi.OpenApiConstants.FORMAT_UUID;
+import static org.citrusframework.openapi.OpenApiConstants.TYPE_BOOLEAN;
+import static org.citrusframework.openapi.OpenApiConstants.TYPE_STRING;
+import static org.citrusframework.openapi.random.RandomGenerator.ANY;
+import static org.citrusframework.openapi.random.RandomGenerator.NULL_GENERATOR;
+import static org.citrusframework.openapi.random.RandomGeneratorBuilder.builder;
+
+import io.apicurio.datamodels.openapi.models.OasSchema;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Configuration class that initializes and manages a list of random generators
+ * for producing random data based on an OpenAPI schema. This class is a singleton
+ * and provides a static instance {@code RANDOM_CONFIGURATION} for global access.
+ */
+public class RandomConfiguration {
+
+ private static final String EMAIL_PATTERN = "[a-z]{5,15}\\.?[a-z]{5,15}\\@[a-z]{5,15}\\.[a-z]{2}";
+ private static final String URI_PATTERN = "((http|https)://[a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+(/[a-zA-Z0-9-]+){1,6})|(file:///[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+){1,6})";
+ private static final String HOSTNAME_PATTERN = "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])";
+ private static final String IPV4_PATTERN = "(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
+ private static final String IPV6_PATTERN = "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
+
+ private final List randomGenerators;
+
+ public static final RandomConfiguration RANDOM_CONFIGURATION = new RandomConfiguration();
+
+ private RandomConfiguration() {
+ List generators = new ArrayList<>();
+
+ // Note that the order of generators in the list is relevant, as the list is traversed from start to end, to find the first matching generator for a schema, and some generators match for less significant schemas.
+ generators.add(new RandomEnumGenerator());
+ generators.add(builder(TYPE_STRING, FORMAT_DATE).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:currentDate('yyyy-MM-dd')")));
+ generators.add(builder(TYPE_STRING, FORMAT_DATE_TIME).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:currentDate('yyyy-MM-dd'T'hh:mm:ssZ')")));
+ generators.add(builder(TYPE_STRING, FORMAT_UUID).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomUUID()")));
+ generators.add(builder(TYPE_STRING, "email").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+EMAIL_PATTERN+"')")));
+ generators.add(builder(TYPE_STRING, "uri").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+URI_PATTERN+"')")));
+ generators.add(builder(TYPE_STRING, "hostname").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+HOSTNAME_PATTERN+"')")));
+ generators.add(builder(TYPE_STRING, "ipv4").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+IPV4_PATTERN+"')")));
+ generators.add(builder(TYPE_STRING,"ipv6").build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+IPV6_PATTERN+"')")));
+ generators.add(builder().withType(TYPE_STRING).withPattern(ANY).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomPattern('"+schema.pattern+"')")));
+ generators.add(builder().withType(TYPE_BOOLEAN).build((randomContext, schema) -> randomContext.getRandomModelBuilder().appendSimple("citrus:randomEnumValue('true', 'false')")));
+ generators.add(new RandomStringGenerator());
+ generators.add(new RandomCompositeGenerator());
+ generators.add(new RandomNumberGenerator());
+ generators.add(new RandomObjectGenerator());
+ generators.add(new RandomArrayGenerator());
+
+ randomGenerators = Collections.unmodifiableList(generators);
+ }
+
+ public RandomGenerator getGenerator(OasSchema oasSchema) {
+ return randomGenerators.stream().filter(generator -> generator.handles(oasSchema))
+ .findFirst()
+ .orElse(NULL_GENERATOR);
+ }
+}
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java
new file mode 100644
index 0000000000..978d3b666b
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomContext.java
@@ -0,0 +1,120 @@
+package org.citrusframework.openapi.random;
+
+import static org.citrusframework.openapi.random.RandomConfiguration.RANDOM_CONFIGURATION;
+
+import io.apicurio.datamodels.openapi.models.OasSchema;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import org.citrusframework.openapi.OpenApiSpecification;
+import org.citrusframework.openapi.model.OasModelHelper;
+
+/**
+ * Context class for generating random values based on an OpenAPI specification.
+ * This class manages the state and configuration needed to generate random values
+ * for various schemas defined in the OpenAPI specification.
+ */
+public class RandomContext {
+
+ private final OpenApiSpecification specification;
+
+ private Map schemaDefinitions;
+
+ private final RandomModelBuilder randomModelBuilder;
+
+ /**
+ * Cache for storing variable during random value generation.
+ */
+ private final Map contextVariables = new HashMap<>();
+
+ /**
+ * Constructs a default RandomContext backed by no specification. Note, that this context can not
+ * resolve referenced schemas, as no specification is available.
+ *
+ */
+ public RandomContext() {
+ this.randomModelBuilder = new RandomModelBuilder(false);
+ this.specification = null;
+ }
+
+ /**
+ * Constructs a new RandomContext with the specified OpenAPI specification and quote option.
+ *
+ * @param specification the OpenAPI specification
+ * @param quote whether to quote the generated random values
+ */
+ public RandomContext(OpenApiSpecification specification, boolean quote) {
+ this.specification = specification;
+ this.randomModelBuilder = new RandomModelBuilder(quote);
+ }
+
+ /**
+ * Generates random values based on the specified schema.
+ *
+ * @param schema the schema to generate random values for
+ */
+ public void generate(OasSchema schema) {
+ doGenerate(resolveSchema(schema));
+ }
+
+ void doGenerate(OasSchema resolvedSchema) {
+ RANDOM_CONFIGURATION.getGenerator(resolvedSchema).generate(this, resolvedSchema);
+ }
+
+ /**
+ * Resolves a schema, handling reference schemas by fetching the referenced schema definition.
+ *
+ * @param schema the schema to resolve
+ * @return the resolved schema
+ */
+ OasSchema resolveSchema(OasSchema schema) {
+ if (OasModelHelper.isReferenceType(schema)) {
+ if (schemaDefinitions == null) {
+ schemaDefinitions = getSchemaDefinitions();
+ }
+ schema = schemaDefinitions.get(OasModelHelper.getReferenceName(schema.$ref));
+ }
+ return schema;
+ }
+
+ /**
+ * Returns the RandomModelBuilder associated with this context.
+ *
+ * @return the RandomModelBuilder
+ */
+ public RandomModelBuilder getRandomModelBuilder() {
+ return randomModelBuilder;
+ }
+
+ /**
+ * Returns the OpenAPI specification associated with this context.
+ *
+ * @return the OpenAPI specification
+ */
+ public OpenApiSpecification getSpecification() {
+ return specification;
+ }
+
+ /**
+ * Returns the schema definitions from the specified OpenAPI document.
+ *
+ * @return a map of schema definitions
+ */
+ Map getSchemaDefinitions() {
+ return specification != null ?OasModelHelper.getSchemaDefinitions(specification.getOpenApiDoc(null)) : Collections.emptyMap();
+ }
+
+ /**
+ * Retrieves a context variable by key, computing its value if necessary using the provided mapping function.
+ *
+ * @param the type of the context variable
+ * @param key the key of the context variable
+ * @param mappingFunction the function to compute the value if it is not present
+ * @return the context variable value
+ */
+ public T get(String key, Function mappingFunction) {
+ //noinspection unchecked
+ return (T) contextVariables.computeIfAbsent(key, mappingFunction);
+ }
+}
\ No newline at end of file
diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomElement.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomElement.java
new file mode 100644
index 0000000000..4c515a4256
--- /dev/null
+++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomElement.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.openapi.random;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+
+/**
+ * Interface representing a random element in a JSON structure. This interface provides default
+ * methods to push values into the element, which can be overridden by implementing classes.
+ */
+public interface RandomElement {
+
+ default void push(Object value) {
+ throw new UnsupportedOperationException();
+ }
+
+ default void push(String key, Object value) {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * A random element representing an array. Array elements can be of type String (native
+ * attribute) or {@link RandomElement}.
+ */
+ class RandomList extends ArrayList
-
jakarta.xml.bindjakarta.xml.bind-apiprovided
+
+ com.github.mifmif
+ generex
+ 1.0.2
+
diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java
index 6111a9d9c3..1613fc19ce 100644
--- a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java
+++ b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java
@@ -31,8 +31,10 @@
import org.citrusframework.functions.core.LowerCaseFunction;
import org.citrusframework.functions.core.MaxFunction;
import org.citrusframework.functions.core.MinFunction;
+import org.citrusframework.functions.core.AdvancedRandomNumberFunction;
import org.citrusframework.functions.core.RandomEnumValueFunction;
import org.citrusframework.functions.core.RandomNumberFunction;
+import org.citrusframework.functions.core.RandomPatternFunction;
import org.citrusframework.functions.core.RandomStringFunction;
import org.citrusframework.functions.core.RandomUUIDFunction;
import org.citrusframework.functions.core.ReadFileResourceFunction;
@@ -66,7 +68,9 @@ public DefaultFunctionLibrary() {
setName("citrusFunctionLibrary");
getMembers().put("randomNumber", new RandomNumberFunction());
+ getMembers().put("randomNumberGenerator", new AdvancedRandomNumberFunction());
getMembers().put("randomString", new RandomStringFunction());
+ getMembers().put("randomPattern", new RandomPatternFunction());
getMembers().put("concat", new ConcatFunction());
getMembers().put("currentDate", new CurrentDateFunction());
getMembers().put("substring", new SubstringFunction());
diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java
new file mode 100644
index 0000000000..6cde8506c4
--- /dev/null
+++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/AdvancedRandomNumberFunction.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.functions.core;
+
+import static java.lang.String.format;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+import org.citrusframework.context.TestContext;
+import org.citrusframework.exceptions.CitrusRuntimeException;
+import org.citrusframework.exceptions.InvalidFunctionUsageException;
+import org.citrusframework.functions.Function;
+
+/**
+ * A function for generating random double values with specified decimal places and range. This
+ * function includes options to specify the number of decimal places, minimum and maximum values,
+ * and whether to include or exclude the minimum and maximum values.
+ *
+ * Parameters:
+ *
+ *
Decimal places: The number of decimal places in the generated random number (optional, default: 0). Note that definition of 0 results in an integer.
+ *
Min value: The minimum value for the generated random number (optional, default: Double.MIN_VALUE).
+ *
Max value: The maximum value for the generated random number (optional, default: Double.MAX_VALUE).
+ *
Exclude min: Whether to exclude the minimum value (optional, default: false).
+ *
Exclude max: Whether to exclude the maximum value (optional, default: false).
+ *
Multiple of: The generated number will be a multiple of this value (optional).
+ *
+ *
+ * This function differs from the {@link RandomNumberFunction} in several key ways:
+ *
+ *
It allows to specify several aspects of a number (see above).
+ *
The length of the number is restricted to the range and precision of a double, whereas RandomNumberFunction can create arbitrarily long integer values.
+ *
+ */
+public class AdvancedRandomNumberFunction implements Function {
+
+ public static final BigDecimal DEFAULT_MAX_VALUE = new BigDecimal(1000000);
+ public static final BigDecimal DEFAULT_MIN_VALUE = DEFAULT_MAX_VALUE.negate();
+
+ public String execute(List parameterList, TestContext context) {
+ if (parameterList == null) {
+ throw new InvalidFunctionUsageException("Function parameters must not be null.");
+ }
+
+ int decimalPlaces = getParameter(parameterList, 0, Integer.class, Integer::parseInt, 2);
+ if (decimalPlaces < 0) {
+ throw new InvalidFunctionUsageException(
+ "Decimal places must be a non-negative integer value.");
+ }
+
+ BigDecimal minValue = getParameter(parameterList, 1, BigDecimal.class, BigDecimal::new,
+ DEFAULT_MIN_VALUE);
+ BigDecimal maxValue = getParameter(parameterList, 2, BigDecimal.class, BigDecimal::new,
+ DEFAULT_MAX_VALUE);
+ if (minValue.compareTo(maxValue) > 0) {
+ throw new InvalidFunctionUsageException("Min value must be less than max value.");
+ }
+
+ boolean excludeMin = getParameter(parameterList, 3, Boolean.class, Boolean::parseBoolean,
+ false);
+ boolean excludeMax = getParameter(parameterList, 4, Boolean.class, Boolean::parseBoolean,
+ false);
+ BigDecimal multiple = getParameter(parameterList, 5, BigDecimal.class, BigDecimal::new,
+ null);
+
+ return getRandomNumber(decimalPlaces, minValue, maxValue, excludeMin, excludeMax, multiple);
+ }
+
+ private T getParameter(List params, int index, Class type,
+ java.util.function.Function parser, T defaultValue) {
+ if (index < params.size()) {
+ String param = params.get(index);
+ return "null".equals(param) ? defaultValue
+ : parseParameter(index + 1, param, type, parser);
+ }
+ return defaultValue;
+ }
+
+ private T parseParameter(int index, String text, Class type,
+ java.util.function.Function parseFunction) {
+ T value;
+ try {
+
+ value = parseFunction.apply(text);
+ if (value == null) {
+ throw new CitrusRuntimeException(
+ "Text '%s' could not be parsed to '%s'. Resulting value is null".formatted(text,
+ type.getSimpleName()));
+ }
+ return value;
+ } catch (Exception e) {
+ throw new InvalidFunctionUsageException(
+ format("Invalid parameter at index %d. %s must be parsable to %s.", index, text,
+ type.getSimpleName()));
+ }
+ }
+
+ /**
+ * Static number generator method.
+ */
+ private String getRandomNumber(int decimalPlaces, BigDecimal minValue, BigDecimal maxValue,
+ boolean excludeMin, boolean excludeMax, BigDecimal multiple) {
+
+ minValue = excludeMin ? incrementToExclude(minValue) : minValue;
+ maxValue = excludeMax ? decrementToExclude(maxValue) : maxValue;
+
+ BigDecimal range = maxValue.subtract(minValue);
+
+ BigDecimal randomValue;
+ if (multiple != null) {
+ randomValue = createMultipleOf(minValue, maxValue, multiple);
+ } else {
+ randomValue = createRandomValue(minValue, range,
+ ThreadLocalRandom.current().nextDouble());
+ randomValue = randomValue.setScale(decimalPlaces, RoundingMode.HALF_UP);
+ }
+
+ if (randomValue == null) {
+ // May only happen if multiple is out of range of min/max
+ return format("%s", Double.POSITIVE_INFINITY);
+ }
+
+ return decimalPlaces == 0 ?
+ format("%s", randomValue.longValue()) :
+ format(format("%%.%sf", decimalPlaces), randomValue.doubleValue());
+ }
+
+ // Pass in random for testing
+ BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) {
+ BigDecimal offset = range.multiply(BigDecimal.valueOf(random));
+ BigDecimal value = minValue.add(offset);
+ return value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0 ? BigDecimal.valueOf(
+ Double.MAX_VALUE) : value;
+ }
+
+ private BigDecimal largestMultipleOf(BigDecimal highest, BigDecimal multipleOf) {
+ RoundingMode roundingMode =
+ highest.compareTo(BigDecimal.ZERO) < 0 ? RoundingMode.UP : RoundingMode.DOWN;
+ BigDecimal factor = highest.divide(multipleOf, 0, roundingMode);
+ return multipleOf.multiply(factor);
+ }
+
+ private BigDecimal lowestMultipleOf(BigDecimal lowest, BigDecimal multipleOf) {
+ RoundingMode roundingMode =
+ lowest.compareTo(java.math.BigDecimal.ZERO) < 0 ? RoundingMode.DOWN : RoundingMode.UP;
+ BigDecimal factor = lowest.divide(multipleOf, 0, roundingMode);
+ return multipleOf.multiply(factor);
+ }
+
+ private BigDecimal incrementToExclude(BigDecimal val) {
+ return val.add(determineIncrement(val))
+ .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN);
+ }
+
+ private BigDecimal decrementToExclude(BigDecimal val) {
+ return val.subtract(determineIncrement(val))
+ .setScale(findLeastSignificantDecimalPlace(val), RoundingMode.HALF_DOWN);
+ }
+
+ private BigDecimal determineIncrement(BigDecimal number) {
+ return java.math.BigDecimal.valueOf(
+ 1.0d / (Math.pow(10d, findLeastSignificantDecimalPlace(number))));
+ }
+
+ private int findLeastSignificantDecimalPlace(BigDecimal number) {
+ number = number.stripTrailingZeros();
+
+ String[] parts = number.toPlainString().split("\\.");
+
+ if (parts.length == 1) {
+ return 0;
+ }
+
+ return parts[1].length();
+ }
+
+ private BigDecimal createMultipleOf(
+ BigDecimal minimum,
+ BigDecimal maximum,
+ BigDecimal multipleOf
+ ) {
+
+ BigDecimal lowestMultiple = lowestMultipleOf(minimum, multipleOf);
+ BigDecimal largestMultiple = largestMultipleOf(maximum, multipleOf);
+
+ // Check if there are no valid multiples in the range
+ if (lowestMultiple.compareTo(largestMultiple) > 0) {
+ return null;
+ }
+
+ BigDecimal range = largestMultiple.subtract(lowestMultiple)
+ .divide(multipleOf, RoundingMode.DOWN);
+
+ // Don't go for incredible large numbers
+ if (range.compareTo(BigDecimal.valueOf(11)) > 0) {
+ range = BigDecimal.valueOf(10);
+ }
+
+ long factor = 0;
+ if (range.compareTo(BigDecimal.ZERO) != 0) {
+ factor = ThreadLocalRandom.current().nextLong(1, range.longValue() + 1);
+ }
+ BigDecimal randomMultiple = lowestMultiple.add(
+ multipleOf.multiply(BigDecimal.valueOf(factor)));
+ randomMultiple = randomMultiple.setScale(findLeastSignificantDecimalPlace(multipleOf),
+ RoundingMode.HALF_UP);
+
+ return randomMultiple;
+ }
+
+}
\ No newline at end of file
diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java
new file mode 100644
index 0000000000..377b2d5fe7
--- /dev/null
+++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomPatternFunction.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.functions.core;
+
+import static org.citrusframework.util.StringUtils.hasText;
+
+import com.mifmif.common.regex.Generex;
+import java.util.List;
+import org.citrusframework.context.TestContext;
+import org.citrusframework.exceptions.InvalidFunctionUsageException;
+import org.citrusframework.functions.Function;
+
+/**
+ * The RandomPatternFunction generates a random string based on a provided regular expression pattern.
+ * It uses the Generex library to generate the random string.
+ *
+ * Note: The Generex library has limitations in its ability to generate all possible expressions
+ * from a given regular expression. It may not support certain complex regex features or produce all
+ * possible variations.
+ */
+public class RandomPatternFunction implements Function {
+
+
+ public String execute(List parameterList, TestContext context) {
+
+ if (parameterList == null) {
+ throw new InvalidFunctionUsageException("Function parameters must not be null.");
+ }
+
+ String pattern = parameterList.get(0);
+
+ if (!hasText(pattern)) {
+ throw new InvalidFunctionUsageException("Pattern must not be empty.");
+ }
+
+ if (!Generex.isValidPattern(pattern)) {
+ throw new IllegalArgumentException(
+ "Function called with a pattern, the algorithm is not able to create a string for.");
+ }
+
+ Generex generex = new Generex(pattern);
+ return generex.random();
+ }
+
+}
\ No newline at end of file
diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java
index a1b03ea154..505b1cd707 100644
--- a/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java
+++ b/core/citrus-base/src/main/java/org/citrusframework/functions/core/RandomStringFunction.java
@@ -19,6 +19,7 @@
import java.util.List;
import java.util.Random;
+import java.util.concurrent.ThreadLocalRandom;
import org.citrusframework.context.TestContext;
import org.citrusframework.exceptions.InvalidFunctionUsageException;
import org.citrusframework.functions.Function;
@@ -33,7 +34,7 @@
* @author Christoph Deppisch
*/
public class RandomStringFunction implements Function {
- private static Random generator = new Random(System.currentTimeMillis());
+ private static final Random generator = new Random(System.currentTimeMillis());
private static final char[] ALPHABET_UPPER = { 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
@@ -68,12 +69,13 @@ public String execute(List parameterList, TestContext context) {
int numberOfLetters;
String notationMethod = MIXED;
boolean includeNumbers = false;
+ int minNumberOfLetters = -1;
if (parameterList == null || parameterList.isEmpty()) {
throw new InvalidFunctionUsageException("Function parameters must not be empty");
}
- if (parameterList.size() > 3) {
+ if (parameterList.size() > 4) {
throw new InvalidFunctionUsageException("Too many parameters for function");
}
@@ -90,12 +92,16 @@ public String execute(List parameterList, TestContext context) {
includeNumbers = parseBoolean(parameterList.get(2));
}
+ if (parameterList.size() > 3) {
+ minNumberOfLetters = parseInt(parameterList.get(3));
+ }
+
if (notationMethod.equals(UPPERCASE)) {
- return getRandomString(numberOfLetters, ALPHABET_UPPER, includeNumbers);
+ return getRandomString(numberOfLetters, ALPHABET_UPPER, includeNumbers, minNumberOfLetters);
} else if (notationMethod.equals(LOWERCASE)) {
- return getRandomString(numberOfLetters, ALPHABET_LOWER, includeNumbers);
+ return getRandomString(numberOfLetters, ALPHABET_LOWER, includeNumbers, minNumberOfLetters);
} else {
- return getRandomString(numberOfLetters, ALPHABET_MIXED, includeNumbers);
+ return getRandomString(numberOfLetters, ALPHABET_MIXED, includeNumbers, minNumberOfLetters);
}
}
@@ -106,7 +112,7 @@ public String execute(List parameterList, TestContext context) {
* @param includeNumbers
* @return
*/
- public static String getRandomString(int numberOfLetters, char[] alphabet, boolean includeNumbers) {
+ public static String getRandomString(int numberOfLetters, char[] alphabet, boolean includeNumbers, int minNumberOfLetters) {
StringBuilder builder = new StringBuilder();
int upperRange = alphabet.length - 1;
@@ -118,6 +124,11 @@ public static String getRandomString(int numberOfLetters, char[] alphabet, boole
upperRange += NUMBERS.length;
}
+ if (minNumberOfLetters > -1) {
+ numberOfLetters = ThreadLocalRandom.current()
+ .nextInt(minNumberOfLetters, numberOfLetters + 1);
+ }
+
for (int i = 1; i < numberOfLetters; i++) {
int letterIndex = generator.nextInt(upperRange);
diff --git a/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java b/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java
index b74b5355ca..c03f2b3263 100644
--- a/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java
+++ b/core/citrus-base/src/main/java/org/citrusframework/message/DefaultMessage.java
@@ -68,6 +68,7 @@ public DefaultMessage() {
* @param message
*/
public DefaultMessage(Message message) {
+
this(message.getPayload(), message.getHeaders());
this.setName(message.getName());
diff --git a/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java b/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java
new file mode 100644
index 0000000000..eae38431bd
--- /dev/null
+++ b/core/citrus-base/src/main/java/org/citrusframework/repository/BaseRepository.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.repository;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import org.citrusframework.common.InitializingPhase;
+import org.citrusframework.common.Named;
+import org.citrusframework.exceptions.CitrusRuntimeException;
+import org.citrusframework.spi.ClasspathResourceResolver;
+import org.citrusframework.spi.Resource;
+import org.citrusframework.spi.Resources;
+import org.citrusframework.util.FileUtils;
+import org.citrusframework.util.StringUtils;
+
+/**
+ * Base class for repositories providing common functionality for initializing and managing resources.
+ * Implementations must provide the logic for loading and adding resources to the repository.
+ */
+public abstract class BaseRepository implements Named, InitializingPhase {
+
+ private String name;
+
+ /** List of location patterns that will be translated to schema resources */
+ private List locations = new ArrayList<>();
+
+ protected BaseRepository(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public void initialize() {
+ try {
+ ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver();
+ for (String location : locations) {
+ Resource found = Resources.create(location);
+ if (found.exists()) {
+ addRepository(found);
+ } else {
+ Set findings;
+ if (StringUtils.hasText(FileUtils.getFileExtension(location))) {
+ String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*");
+ String basePath = FileUtils.getBasePath(location);
+ findings = resourceResolver.getResources(basePath, fileNamePattern);
+ } else {
+ findings = resourceResolver.getResources(location);
+ }
+
+ for (Path resource : findings) {
+ addRepository(Resources.fromClasspath(resource.toString()));
+ }
+ }
+ }
+ } catch (IOException e) {
+ throw new CitrusRuntimeException("Failed to initialize repository", e);
+ }
+ }
+
+ protected abstract void addRepository(Resource resource);
+
+ @Override
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Gets the name.
+ * @return the name to get.
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Gets the locations.
+ * @return the locations to get.
+ */
+ public List getLocations() {
+ return locations;
+ }
+
+ /**
+ * Sets the locations.
+ * @param locations the locations to set
+ */
+ public void setLocations(List locations) {
+ this.locations = locations;
+ }
+
+}
diff --git a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java
index b1f208a21b..629e279c19 100644
--- a/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java
+++ b/core/citrus-base/src/main/java/org/citrusframework/util/StringUtils.java
@@ -21,6 +21,8 @@
*/
public class StringUtils {
+ public static final String URL_PATH_SEPARATOR = "/";
+
private StringUtils() {
//prevent instantiation of utility class
}
@@ -42,4 +44,52 @@ public static boolean hasText(String str) {
public static boolean isEmpty(String str) {
return str == null || str.isEmpty();
}
+
+ public static String appendSegmentToUrlPath(String path, String segment) {
+
+ if (path == null) {
+ return segment;
+ }
+
+ if (segment == null) {
+ return path;
+ }
+
+ if (!path.endsWith(URL_PATH_SEPARATOR)) {
+ path = path + URL_PATH_SEPARATOR;
+ }
+
+ if (segment.startsWith(URL_PATH_SEPARATOR)) {
+ segment = segment.substring(1);
+ }
+
+ return path + segment;
+ }
+
+ public static String quote(String text, boolean quote) {
+ return quote ? "\"" + text + "\"" : text;
+ }
+
+ /**
+ * Trims trailing whitespace characters and the first trailing comma from the end of the given StringBuilder.
+ *
+ * This method removes all trailing whitespace characters (such as spaces, tabs, and newline characters)
+ * and the first trailing comma found from the end of the content in the provided StringBuilder.
+ * Any additional commas or whitespace characters after the first trailing comma are not removed.
+ *
+ * @param builder the StringBuilder whose trailing whitespace characters and first comma are to be removed
+ */
+ public static void trimTrailingComma(StringBuilder builder) {
+ int length = builder.length();
+ while (length > 0 && (builder.charAt(length - 1) == ',' || Character.isWhitespace(builder.charAt(length - 1)))) {
+ char c = builder.charAt(length - 1);
+ builder.deleteCharAt(length - 1);
+
+ if (c == ',') {
+ return;
+ }
+
+ length = builder.length();
+ }
+ }
}
diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java
new file mode 100644
index 0000000000..82cf51cfa7
--- /dev/null
+++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/AdvancedRandomNumberFunctionTest.java
@@ -0,0 +1,416 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.functions.core;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+import java.math.BigDecimal;
+import java.util.List;
+import org.citrusframework.context.TestContext;
+import org.citrusframework.exceptions.InvalidFunctionUsageException;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+public class AdvancedRandomNumberFunctionTest {
+
+ private AdvancedRandomNumberFunction function;
+ private TestContext context;
+
+ @BeforeMethod
+ public void setUp() {
+ function = new AdvancedRandomNumberFunction();
+ context = new TestContext();
+ }
+
+ @Test
+ public void testRandomNumberWithNullParameter() {
+ InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class,
+ () -> function.execute(null, context));
+ assertEquals(exception.getMessage(),
+ "Function parameters must not be null.");
+ }
+
+ @Test
+ public void testRandomNumberWithDefaultValues() {
+ List params = List.of();
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ assertTrue(result.matches("-?\\d*\\.\\d{2}"));
+ }
+
+ @Test
+ public void testRandomNumberWithDecimalPlaces() {
+ List params = List.of("2");
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ assertTrue(result.matches("-?\\d*\\.\\d{2}"), "result does not match pattern: " + result);
+ }
+
+ @Test
+ public void testRandomNumberWithinRange() {
+ List params = List.of("2", "10.5", "20.5");
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue >= 10.5 && randomValue <= 20.5);
+ }
+
+ @Test
+ public void testRandomNumberIncludesMin() {
+ List params = List.of("1", "10.5", "20.5");
+ function = new AdvancedRandomNumberFunction() {
+ @Override
+ BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) {
+ random = 0.0;
+ return super.createRandomValue(minValue, range, random);
+ }
+ };
+ String result = function.execute(params, context);
+ assertEquals(result, "10.5");
+ }
+
+ @Test
+ public void testRandomNumberIncludesMax() {
+ List params = List.of("1", "10.5", "20.5");
+ function = new AdvancedRandomNumberFunction() {
+ @Override
+ BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) {
+ random = 1.0;
+ return super.createRandomValue(minValue, range, random);
+ }
+ };
+ String result = function.execute(params, context);
+ assertEquals(result, "20.5");
+ }
+
+ @Test
+ public void testRandomNumberExcludeMin() {
+ List params = List.of("1", "10.5", "20.5", "true", "false");
+ function = new AdvancedRandomNumberFunction() {
+ @Override
+ BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) {
+ random = 0.0;
+ return super.createRandomValue(minValue, range, random);
+ }
+ };
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue > 10.5 && randomValue <= 20.5);
+ }
+
+ @Test
+ public void testRandomNumberExcludeMax() {
+ List params = List.of("2", "10.5", "20.5", "false", "true");
+ function = new AdvancedRandomNumberFunction() {
+ @Override
+ BigDecimal createRandomValue(BigDecimal minValue, BigDecimal range, double random) {
+ random = 1.0;
+ return super.createRandomValue(minValue, range, random);
+ }
+ };
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue >= 10.5 && randomValue < 20.5);
+ }
+
+ @Test
+ public void testRandomInteger32EdgeCase() {
+ List params = List.of("0", "-2147483648", "2147483647", "false", "false");
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue >= -Integer.MAX_VALUE && randomValue < Integer.MAX_VALUE);
+ }
+
+ @Test
+ public void testRandomInteger32MinEqualsMaxEdgeCase() {
+ List params = List.of("0", "3", "3", "false", "false");
+ for (int i = 0; i < 100; i++) {
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertEquals(randomValue, 3);
+ }
+ }
+
+ // randomDouble('0','3','3','true','true')
+ // randomDouble('0','3','3','true','true')
+
+ @Test
+ public void testRandomDouble32MinEqualsMaxEdgeCase() {
+ List params = List.of("2", "3.0", "3.0", "false", "false");
+ for (int i = 0; i < 100; i++) {
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertEquals(randomValue, 3);
+ }
+ }
+
+ @Test
+ public void testRandomInteger64EdgeCase() {
+ List params = List.of("0", "-9223372036854775808", "9223372036854775807", "false",
+ "false");
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue >= -Long.MAX_VALUE && randomValue < Long.MAX_VALUE);
+ }
+
+ @Test
+ public void testRandomNumberFloatEdgeCase() {
+ List params = List.of("0", "-3.4028235E38", "3.4028235E38", "false", "false");
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue >= -Float.MAX_VALUE && randomValue < Float.MAX_VALUE);
+ }
+
+ @Test
+ public void testRandomNumberDoubleEdgeCase() {
+ List params = List.of("0", "-1.7976931348623157E308", "1.7976931348623157E308",
+ "false", "false");
+ String result = function.execute(params, context);
+ assertNotNull(result);
+ double randomValue = Double.parseDouble(result);
+ assertTrue(randomValue >= -Double.MAX_VALUE && randomValue < Double.MAX_VALUE);
+ }
+
+ @Test
+ public void testInvalidDecimalPlaces() {
+ List params = List.of("-1"); // invalid decimalPlaces
+ InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class,
+ () -> function.execute(params, context));
+ assertEquals(exception.getMessage(),
+ "Decimal places must be a non-negative integer value.");
+ }
+
+ @Test
+ public void testInvalidRange() {
+ List params = List.of("2", "20.5", "10.5"); // invalid range
+ InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class,
+ () -> function.execute(params, context));
+ assertEquals(exception.getMessage(),
+ "Min value must be less than max value.");
+ }
+
+ @Test
+ public void testInvalidDecimalPlacesFormat() {
+ List params = List.of("xxx"); // invalid decimalPlaces
+ InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class,
+ () -> function.execute(params, context));
+ assertEquals(exception.getMessage(),
+ "Invalid parameter at index 1. xxx must be parsable to Integer.");
+ }
+
+ @Test
+ public void testInvalidMinValueFormat() {
+ List params = List.of("1", "xxx"); // invalid min value
+ InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class,
+ () -> function.execute(params, context));
+ assertEquals(exception.getMessage(),
+ "Invalid parameter at index 2. xxx must be parsable to BigDecimal.");
+ }
+
+ @Test
+ public void testInvalidMaxValueFormat() {
+ List params = List.of("1", "1.1", "xxx"); // invalid max value
+ InvalidFunctionUsageException exception = expectThrows(InvalidFunctionUsageException.class,
+ () -> function.execute(params, context));
+ assertEquals(exception.getMessage(),
+ "Invalid parameter at index 3. xxx must be parsable to BigDecimal.");
+ }
+
+ @DataProvider(name = "testRandomNumber")
+ public static Object[][] testRandomNumber() {
+ return new Object[][]{
+
+ {0, 12, null, null, false, false},
+ {0, null, 0, 2, true, true},
+ {0, null, null, null, false, false},
+ {0, null, 0, 100, false, false},
+ {0, null, 0, 2, false, false},
+ {0, null, -100, 0, false, false},
+ {0, null, -2, 0, false, false},
+ {0, null, 0, 100, true, true},
+ {0, null, -100, 0, true, true},
+ {0, null, -2, 0, true, true},
+ {0, null, 0, null, false, false},
+ {0, null, 0, 0, false, false},
+ {0, 11, 0, 12, true, true},
+
+ {0, 13, 0, 100, false, false},
+ {0, 14, 0, 14, false, false},
+ {0, 15, -100, 0, false, false},
+ {0, 16, -16, 0, false, false},
+ {0, 17, 0, 100, true, true},
+ {0, 18, -100, 0, true, true},
+ {0, 19, -20, 0, true, true},
+ {0, 20, 0, null, false, false},
+ {0, 21, 21, 21, false, false},
+
+ {0, null, 0, 2, true, true},
+ {0, null, null, null, false, false},
+ {0, null, 0, 100, false, false},
+ {0, null, 0, 2, false, false},
+ {0, null, -100, 0, false, false},
+ {0, null, -2, 0, false, false},
+ {0, null, 0, 100, true, true},
+ {0, null, -100, 0, true, true},
+ {0, null, -2, 0, true, true},
+ {0, null, 0, null, false, false},
+ {0, null, 0, 0, false, false},
+ {0, 11, 0, 12, true, true},
+ {0, 12, null, null, false, false},
+ {0, 13, 0, 100, false, false},
+ {0, 14, 0, 14, false, false},
+ {0, 15, -100, 0, false, false},
+ {0, 16, -16, 0, false, false},
+ {0, 17, 0, 100, true, true},
+ {0, 18, -100, 0, true, true},
+ {0, 19, -20, 0, true, true},
+ {0, 20, 0, null, false, false},
+ {0, 21, 21, 21, false, false},
+
+ {3, null, 0, 2, true, true},
+ {3, null, null, null, false, false},
+ {3, null, 0, 100, false, false},
+ {3, null, 0, 2, false, false},
+ {3, null, -100, 0, false, false},
+ {3, null, -2, 0, false, false},
+ {3, null, 0, 100, true, true},
+ {3, null, -100, 0, true, true},
+ {3, null, -2, 0, true, true},
+ {3, null, 0, null, false, false},
+ {3, null, 0, 0, false, false},
+ {3, 11.123f, 0, 13, true, true},
+ {3, 12.123f, null, null, false, false},
+ {3, 13.123f, 0, 100, false, false},
+ {3, 14.123f, 0, 14, false, false},
+ {3, 15.123f, -100, 0, false, false},
+ {3, 16.123f, -16, 0, false, false},
+ {3, 17.123f, 0, 100, true, true},
+ {3, 18.123f, -100, 0, true, true},
+ {3, 19.123f, -21, 0, true, true},
+ {3, 20.123f, 0, null, false, false},
+ {3, 21.123f, 21.122f, 21.124f, false, false},
+
+ {5, null, 0, 2, true, true},
+ {5, null, null, null, false, false},
+ {5, null, 0, 100, false, false},
+ {5, null, 0, 2, false, false},
+ {5, null, -100, 0, false, false},
+ {5, null, -2, 0, false, false},
+ {5, null, 0, 100, true, true},
+ {5, null, -100, 0, true, true},
+ {5, null, -2, 0, true, true},
+ {5, null, 0, null, false, false},
+ {5, null, 0, 0, false, false},
+ {5, 11.123d, 0, 13, true, true},
+ {5, 12.123d, null, null, false, false},
+ {5, 13.123d, 0, 100, false, false},
+ {5, 14.123d, 0, 14, false, false},
+ {5, 15.123d, -100, 0, false, false},
+ {5, 16.123d, -16, 0, false, false},
+ {5, 17.123d, 0, 100, true, true},
+ {5, 18.123d, -100, 0, true, true},
+ {5, 19.123d, -21, 0, true, true},
+ {5, 20.123d, 0, null, false, false},
+ {5, 21.123d, 21.122d, 21.124d, false, false},
+
+ };
+ }
+
+ @Test(dataProvider = "testRandomNumber")
+ void testRandomNumber(Number decimalPlaces, Number multipleOf, Number minimum, Number maximum,
+ boolean exclusiveMinimum, boolean exclusiveMaximum) {
+
+ TestContext testContext = new TestContext();
+ AdvancedRandomNumberFunction advancedRandomNumberFunction = new AdvancedRandomNumberFunction();
+ try {
+ for (int i = 0; i < 1000; i++) {
+
+ BigDecimal value = new BigDecimal(advancedRandomNumberFunction.execute(
+ List.of(toString(decimalPlaces), toString(minimum), toString(maximum),
+ toString(exclusiveMinimum), toString(exclusiveMaximum), toString(multipleOf)), testContext));
+
+ if (multipleOf != null) {
+ BigDecimal remainder = value.remainder(new BigDecimal(multipleOf.toString()));
+
+ assertEquals(
+ remainder.compareTo(BigDecimal.ZERO), 0,
+ "Expected %s to be a multiple of %s! Remainder is %s".formatted(
+ value, multipleOf,
+ remainder));
+ }
+
+ if (maximum != null) {
+ if (exclusiveMaximum) {
+ assertTrue(value.doubleValue() < maximum.doubleValue(),
+ "Expected %s to be lower than %s!".formatted(
+ value, maximum));
+ } else {
+ assertTrue(value.doubleValue() <= maximum.doubleValue(),
+ "Expected %s to be lower or equal than %s!".formatted(
+ value, maximum));
+ }
+ }
+
+ if (minimum != null) {
+ if (exclusiveMinimum) {
+ assertTrue(value.doubleValue() > minimum.doubleValue(),
+ "Expected %s to be larger than %s!".formatted(
+ value, minimum));
+ } else {
+ assertTrue(value.doubleValue() >= minimum.doubleValue(),
+ "Expected %s to be larger or equal than %s!".formatted(
+ value, minimum));
+ }
+ }
+ }
+ } catch (Exception e) {
+ Assert.fail("Creation of multiple float threw an exception: " + e.getMessage(), e);
+ }
+ }
+
+ private String toString(Object obj) {
+ if (obj == null) {
+ return "null";
+ }
+ return obj.toString();
+
+ }
+
+ private T expectThrows(Class exceptionClass, Runnable runnable) {
+ try {
+ runnable.run();
+ } catch (Throwable throwable) {
+ if (exceptionClass.isInstance(throwable)) {
+ return exceptionClass.cast(throwable);
+ } else {
+ throw new AssertionError("Unexpected exception type", throwable);
+ }
+ }
+ throw new AssertionError("Expected exception not thrown");
+ }
+}
\ No newline at end of file
diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java
new file mode 100644
index 0000000000..dccbb4b0fc
--- /dev/null
+++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomPatternFunctionTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.functions.core;
+
+import static org.testng.Assert.assertTrue;
+
+import java.util.List;
+import org.citrusframework.context.TestContext;
+import org.citrusframework.exceptions.InvalidFunctionUsageException;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+public class RandomPatternFunctionTest {
+
+ private final RandomPatternFunction function = new RandomPatternFunction();
+ private final TestContext context = new TestContext();
+
+ @Test(expectedExceptions = InvalidFunctionUsageException.class)
+ public void testExecuteWithNullParameterList() {
+ function.execute(null, context);
+ }
+
+ @Test(expectedExceptions = InvalidFunctionUsageException.class)
+ public void testExecuteWithEmptyPattern() {
+ function.execute(List.of(""), context);
+ }
+
+ @Test
+ public void testExecuteWithValidPattern() {
+ String pattern = "[a-zA-Z0-9]{10}";
+ String result = function.execute(List.of(pattern), context);
+ assertTrue(result.matches(pattern), "Generated string does not match the pattern");
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testExecuteWithInvalidPattern() {
+ String pattern = "[0-3]([a-c]|[e-g]{1"; // Invalid regex pattern with "Character range is out of order"
+ function.execute(List.of(pattern), context);
+ }
+
+ @DataProvider(name = "patternProvider")
+ public Object[][] patternProvider() {
+ return new Object[][]{
+ {"testExecuteWithComplexPattern", "(foo|bar)[0-9]{2,4}"},
+ {"testIpv6", "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"},
+ {"testIpv4", "(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}"},
+ {"testEmail", "[a-z]{5,15}\\.?[a-z]{5,15}\\@[a-z]{5,15}\\.[a-z]{2}"},
+ {"testUri", "((http|https)://[a-zA-Z0-9-]+(\\.[a-zA-Z]{2,})+(/[a-zA-Z0-9-]+){1,6})|(file:///[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+){1,6})"}
+ };
+ }
+
+ @Test(dataProvider = "patternProvider")
+ public void testPatterns(String description, String pattern) {
+ for (int i = 0; i < 100; i++) {
+ String result = function.execute(List.of(pattern), context);
+ assertTrue(result.matches(pattern), "Generated string does not match the pattern: " + description);
+ }
+ }
+
+
+}
diff --git a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java
index 0d06a912b1..a70dd7c8cf 100644
--- a/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java
+++ b/core/citrus-base/src/test/java/org/citrusframework/functions/core/RandomStringFunctionTest.java
@@ -17,8 +17,10 @@
package org.citrusframework.functions.core;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import org.citrusframework.UnitTestSupport;
import org.citrusframework.exceptions.InvalidFunctionUsageException;
import org.testng.Assert;
@@ -31,7 +33,8 @@
* @author Christoph Deppisch
*/
public class RandomStringFunctionTest extends UnitTestSupport {
- private RandomStringFunction function = new RandomStringFunction();
+
+ private final RandomStringFunction function = new RandomStringFunction();
@Test
public void testFunction() {
@@ -113,8 +116,31 @@ public void testTooManyParameters() {
params.add("3");
params.add("UPPERCASE");
params.add("true");
- params.add("too much");
+ params.add("0");
+ params.add("too many");
function.execute(params, context);
}
-}
+
+ @Test
+ public void testRandomSize() {
+ List params;
+ params = new ArrayList<>();
+ params.add("10");
+ params.add("UPPERCASE");
+ params.add("true");
+ params.add("8");
+
+ Set sizes = new HashSet<>();
+
+ for (int i = 0; i < 1000; i++) {
+ String text = function.execute(params, context);
+ sizes.add(text.length());
+ }
+
+ Assert.assertTrue(sizes.contains(8));
+ Assert.assertTrue(sizes.contains(9));
+ Assert.assertTrue(sizes.contains(10));
+
+ }
+}
\ No newline at end of file
diff --git a/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java
new file mode 100644
index 0000000000..2d78984fde
--- /dev/null
+++ b/core/citrus-base/src/test/java/org/citrusframework/util/StringUtilsTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.citrusframework.util;
+
+import static org.citrusframework.util.StringUtils.quote;
+import static org.citrusframework.util.StringUtils.trimTrailingComma;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+public class StringUtilsTest {
+
+ @Test
+ public void appendSegmentToPath() {
+ assertEquals(StringUtils.appendSegmentToUrlPath("s1","s2"), "s1/s2");
+ assertEquals(StringUtils.appendSegmentToUrlPath("s1/","s2"), "s1/s2");
+ assertEquals(StringUtils.appendSegmentToUrlPath("s1/","/s2"), "s1/s2");
+ assertEquals(StringUtils.appendSegmentToUrlPath("/s1","/s2"), "/s1/s2");
+ assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2"), "/s1/s2");
+ assertEquals(StringUtils.appendSegmentToUrlPath("/s1/","/s2/"), "/s1/s2/");
+ assertEquals(StringUtils.appendSegmentToUrlPath("/s1/",null), "/s1/");
+ assertEquals(StringUtils.appendSegmentToUrlPath(null,"/s2/"), "/s2/");
+ Assert.assertNull(StringUtils.appendSegmentToUrlPath(null,null));
+ }
+
+ @Test
+ public void testQuoteTrue() {
+ String input = "Hello, World!";
+ String expected = "\"Hello, World!\"";
+ String result = quote(input, true);
+
+ assertEquals(result, expected, "The text should be quoted.");
+ }
+
+ @Test
+ public void testQuoteFalse() {
+ String input = "Hello, World!";
+ String expected = "Hello, World!";
+ String result = quote(input, false);
+
+ assertEquals(result, expected, "The text should not be quoted.");
+ }
+
+ @Test
+ public void testQuoteEmptyStringTrue() {
+ String input = "";
+ String expected = "\"\"";
+ String result = quote(input, true);
+
+ assertEquals(result, expected, "The empty text should be quoted.");
+ }
+
+ @Test
+ public void testQuoteEmptyStringFalse() {
+ String input = "";
+ String expected = "";
+ String result = quote(input, false);
+
+ assertEquals(result, expected, "The empty text should not be quoted.");
+ }
+
+ @Test
+ public void testQuoteNullStringTrue() {
+ String input = null;
+ String expected = "\"null\"";
+ String result = quote(input, true);
+
+ assertEquals(result, expected, "The null text should be treated as a string 'null'.");
+ }
+
+ @Test
+ public void testQuoteNullStringFalse() {
+ String input = null;
+ String result = quote(input, false);
+
+ assertNull(result);
+ }
+
+ @DataProvider(name = "trimTrailingCommaDataProvider")
+ public Object[][] trimTrailingCommaDataProvider() {
+ return new Object[][] {
+ { new StringBuilder("Example text, "), "Example text" },
+ { new StringBuilder("No trailing comma "), "No trailing comma" },
+ { new StringBuilder("No trailing comma,\n\t\n "), "No trailing comma" },
+ { new StringBuilder("Trailing comma,"), "Trailing comma" },
+ { new StringBuilder("Multiple commas and spaces,,, "), "Multiple commas and spaces,," },
+ { new StringBuilder("No trim needed"), "No trim needed" },
+ { new StringBuilder(), "" }
+ };
+ }
+
+ @Test(dataProvider = "trimTrailingCommaDataProvider")
+ public void testTrimTrailingComma(StringBuilder input, String expected) {
+ trimTrailingComma(input);
+ Assert.assertEquals(input.toString(), expected);
+ }
+
+ @Test
+ public void testTrimTrailingCommaOnlySpaces() {
+ StringBuilder builder = new StringBuilder(" ");
+ trimTrailingComma(builder);
+ Assert.assertEquals(builder.toString(), "");
+
+ builder = new StringBuilder(",");
+ trimTrailingComma(builder);
+ Assert.assertEquals(builder.toString(), "");
+
+ builder = new StringBuilder(", , ");
+ trimTrailingComma(builder);
+ Assert.assertEquals(builder.toString(), ", ");
+ }
+
+ @Test
+ public void testTrimTrailingCommaWithNull() {
+ StringBuilder builder = new StringBuilder();
+ trimTrailingComma(builder);
+ Assert.assertEquals(builder.toString(), "");
+ }
+
+}
diff --git a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java
index 9445c64406..548cdbc270 100644
--- a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java
+++ b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessage.java
@@ -371,7 +371,11 @@ public String getContentType() {
* @return The accept header value
*/
public String getAccept() {
- final Object accept = getHeader("Accept");
+ Object accept = getHeader("Accept");
+
+ if (accept == null) {
+ accept = getHeader("accept");
+ }
if (accept != null) {
return accept.toString();
diff --git a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java
index 12a4b9f6cd..f615335225 100644
--- a/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java
+++ b/endpoints/citrus-http/src/main/java/org/citrusframework/http/message/HttpMessageUtils.java
@@ -16,6 +16,15 @@
package org.citrusframework.http.message;
+import static org.citrusframework.http.message.HttpMessageHeaders.HTTP_QUERY_PARAMS;
+import static org.citrusframework.util.StringUtils.hasText;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.tuple.Pair;
import org.citrusframework.message.Message;
import org.citrusframework.message.MessageHeaders;
@@ -39,8 +48,8 @@ private HttpMessageUtils() {
*/
public static void copy(Message from, HttpMessage to) {
HttpMessage source;
- if (from instanceof HttpMessage) {
- source = (HttpMessage) from;
+ if (from instanceof HttpMessage httpMessage) {
+ source = httpMessage;
} else {
source = new HttpMessage(from);
}
@@ -66,4 +75,30 @@ public static void copy(HttpMessage from, HttpMessage to) {
from.getHeaderData().forEach(to::addHeaderData);
from.getCookies().forEach(to::cookie);
}
+
+ /**
+ * Extracts query parameters from the citrus HTTP message header and returns them as a map.
+ *
+ * @param httpMessage the HTTP message containing the query parameters in the header
+ * @return a map of query parameter names and their corresponding values
+ * @throws IllegalArgumentException if the query parameters are not formatted correctly
+ */
+ public static Map> getQueryParameterMap(HttpMessage httpMessage) {
+ String queryParams = (String) httpMessage.getHeader(HTTP_QUERY_PARAMS);
+ if (hasText(queryParams)) {
+ return Arrays.stream(queryParams.split(","))
+ .map(queryParameterKeyValue -> {
+ String[] keyAndValue = queryParameterKeyValue.split("=", 2);
+ if (keyAndValue.length == 0) {
+ throw new IllegalArgumentException("Query parameter must have a key.");
+ }
+ String key = keyAndValue[0];
+ String value = keyAndValue.length > 1 ? keyAndValue[1] : "";
+ return Pair.of(key, value);
+ })
+ .collect(Collectors.groupingBy(
+ Pair::getLeft, Collectors.mapping(Pair::getRight, Collectors.toList())));
+ }
+ return Collections.emptyMap();
+ }
}
diff --git a/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java b/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java
index d8f9099a11..896634fbfb 100644
--- a/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java
+++ b/endpoints/citrus-http/src/test/java/org/citrusframework/http/message/HttpMessageUtilsTest.java
@@ -16,15 +16,24 @@
package org.citrusframework.http.message;
+import static org.citrusframework.http.message.HttpMessageHeaders.HTTP_COOKIE_PREFIX;
+import static org.citrusframework.http.message.HttpMessageUtils.getQueryParameterMap;
+import static org.citrusframework.message.MessageHeaders.ID;
+import static org.citrusframework.message.MessageHeaders.MESSAGE_TYPE;
+import static org.citrusframework.message.MessageHeaders.TIMESTAMP;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
import jakarta.servlet.http.Cookie;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-
import org.citrusframework.message.DefaultMessage;
import org.citrusframework.message.Message;
-import org.citrusframework.message.MessageHeaders;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -47,16 +56,16 @@ public void testCopy() {
HttpMessageUtils.copy(from, to);
- Assert.assertNotEquals(from.getId(), to.getId());
- Assert.assertEquals(to.getName(), "FooMessage");
- Assert.assertEquals(to.getPayload(String.class), "fooMessage");
- Assert.assertEquals(to.getHeaders().size(), 4L);
- Assert.assertNotNull(to.getHeader(MessageHeaders.ID));
- Assert.assertNotNull(to.getHeader(MessageHeaders.MESSAGE_TYPE));
- Assert.assertNotNull(to.getHeader(MessageHeaders.TIMESTAMP));
- Assert.assertEquals(to.getHeader("X-Foo"), "foo");
- Assert.assertEquals(to.getHeaderData().size(), 1L);
- Assert.assertEquals(to.getHeaderData().get(0), "HeaderData");
+ assertNotEquals(from.getId(), to.getId());
+ assertEquals(to.getName(), "FooMessage");
+ assertEquals(to.getPayload(String.class), "fooMessage");
+ assertEquals(to.getHeaders().size(), 4L);
+ assertNotNull(to.getHeader(ID));
+ assertNotNull(to.getHeader(MESSAGE_TYPE));
+ assertNotNull(to.getHeader(TIMESTAMP));
+ assertEquals(to.getHeader("X-Foo"), "foo");
+ assertEquals(to.getHeaderData().size(), 1L);
+ assertEquals(to.getHeaderData().get(0), "HeaderData");
}
@Test
@@ -77,20 +86,20 @@ public void testCopyPreventExistingOverwritePayload() {
HttpMessageUtils.copy(from, to);
- Assert.assertNotEquals(from.getId(), to.getId());
- Assert.assertEquals(to.getName(), "FooMessage");
- Assert.assertEquals(to.getPayload(String.class), "fooMessage");
- Assert.assertEquals(to.getHeaders().size(), 7L);
- Assert.assertNotNull(to.getHeader(MessageHeaders.ID));
- Assert.assertNotNull(to.getHeader(MessageHeaders.MESSAGE_TYPE));
- Assert.assertNotNull(to.getHeader(MessageHeaders.TIMESTAMP));
- Assert.assertEquals(to.getHeader("X-Foo"), "foo");
- Assert.assertEquals(to.getHeader("X-Existing"), "existing");
- Assert.assertEquals(to.getHeader(HttpMessageHeaders.HTTP_COOKIE_PREFIX + "Foo"), "Foo=fooCookie");
- Assert.assertEquals(to.getHeader(HttpMessageHeaders.HTTP_COOKIE_PREFIX + "Existing"), "Existing=existingCookie");
- Assert.assertEquals(to.getHeaderData().size(), 2L);
- Assert.assertEquals(to.getHeaderData().get(0), "ExistingHeaderData");
- Assert.assertEquals(to.getHeaderData().get(1), "HeaderData");
+ assertNotEquals(from.getId(), to.getId());
+ assertEquals(to.getName(), "FooMessage");
+ assertEquals(to.getPayload(String.class), "fooMessage");
+ assertEquals(to.getHeaders().size(), 7L);
+ assertNotNull(to.getHeader(ID));
+ assertNotNull(to.getHeader(MESSAGE_TYPE));
+ assertNotNull(to.getHeader(TIMESTAMP));
+ assertEquals(to.getHeader("X-Foo"), "foo");
+ assertEquals(to.getHeader("X-Existing"), "existing");
+ assertEquals(to.getHeader(HTTP_COOKIE_PREFIX + "Foo"), "Foo=fooCookie");
+ assertEquals(to.getHeader(HTTP_COOKIE_PREFIX + "Existing"), "Existing=existingCookie");
+ assertEquals(to.getHeaderData().size(), 2L);
+ assertEquals(to.getHeaderData().get(0), "ExistingHeaderData");
+ assertEquals(to.getHeaderData().get(1), "HeaderData");
}
@Test
@@ -105,19 +114,19 @@ public void testConvertAndCopy() {
HttpMessageUtils.copy(from, to);
- Assert.assertNotEquals(from.getId(), to.getId());
- Assert.assertEquals(to.getName(), "FooMessage");
- Assert.assertEquals(to.getPayload(String.class), "fooMessage");
- Assert.assertEquals(to.getHeader("X-Foo"), "foo");
- Assert.assertEquals(to.getHeaderData().size(), 1L);
- Assert.assertEquals(to.getHeaderData().get(0), "HeaderData");
+ assertNotEquals(from.getId(), to.getId());
+ assertEquals(to.getName(), "FooMessage");
+ assertEquals(to.getPayload(String.class), "fooMessage");
+ assertEquals(to.getHeader("X-Foo"), "foo");
+ assertEquals(to.getHeaderData().size(), 1L);
+ assertEquals(to.getHeaderData().get(0), "HeaderData");
}
@Test(dataProvider = "queryParamStrings")
public void testQueryParamsExtraction(String queryParamString, Map params) {
HttpMessage message = new HttpMessage();
message.queryParams(queryParamString);
- Assert.assertEquals(message.getQueryParams().size(), params.size());
+ assertEquals(message.getQueryParams().size(), params.size());
params.forEach((key, value) -> Assert.assertTrue(message.getQueryParams().get(key).contains(value)));
}
@@ -137,4 +146,55 @@ public Object[][] queryParamStrings() {
.collect(Collectors.toMap(keyValue -> keyValue[0], keyValue -> keyValue[1])) }
};
}
+
+
+ @Test
+ public void testGetQueryParameterMapWithValues() {
+ HttpMessage httpMessage = new HttpMessage();
+ httpMessage.queryParam("q1", "v1");
+ httpMessage.queryParam("q1", "v2");
+ httpMessage.queryParam("q2", "v3");
+ httpMessage.queryParam("q2", "v4");
+ httpMessage.queryParam("q3", "v5");
+
+ Map> queryParams = getQueryParameterMap(httpMessage);
+
+ assertEquals(queryParams.size(), 3);
+ List q1Values = queryParams.get("q1");
+ assertTrue(q1Values.contains("v1"));
+ assertTrue(q1Values.contains("v2"));
+ List q2Values = queryParams.get("q2");
+ assertTrue(q2Values.contains("v3"));
+ assertTrue(q2Values.contains("v4"));
+ List q3Values = queryParams.get("q3");
+ assertTrue(q3Values.contains("v5"));
+ }
+
+ @Test
+ public void testGetQueryParameterMapWithNoValues() {
+ HttpMessage httpMessage = new HttpMessage();
+
+ Map> queryParams = getQueryParameterMap(httpMessage);
+
+ assertTrue(queryParams.isEmpty());
+ }
+
+ @Test
+ public void testGetQueryParameterMapWithMissingValues() {
+ HttpMessage httpMessage = new HttpMessage();
+ httpMessage.queryParam("q1", "");
+ httpMessage.queryParam("q2", "");
+ httpMessage.queryParam("q3", "");
+
+ Map> queryParams = getQueryParameterMap(httpMessage);
+
+ assertEquals(queryParams.size(), 3);
+ List q1Values = queryParams.get("q1");
+ assertTrue(q1Values.contains(""));
+ List q2Values = queryParams.get("q2");
+ assertTrue(q2Values.contains(""));
+ List q3Values = queryParams.get("q3");
+ assertTrue(q3Values.contains(""));
+ }
+
}
diff --git a/pom.xml b/pom.xml
index 5043e6ab0c..8c69dec341 100644
--- a/pom.xml
+++ b/pom.xml
@@ -192,6 +192,7 @@
1.10.144.6.01.1.27
+ com.atlassian.oai1.8.03.25.11.78.1
@@ -581,6 +582,18 @@
${apicurio.data-models.version}
+
+ com.atlassian.oai
+ swagger-request-validator-core
+ ${swagger-request-validator.version}
+
+
+ commons-logging
+ commons-logging
+
+
+
+
org.eclipse.jetty
@@ -1183,6 +1196,12 @@
mockito-junit-jupitertest
+
+ uk.org.webcompere
+ system-stubs-core
+ 2.1.6
+ test
+
diff --git a/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java b/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java
index dcdb87870b..1a4c5d7107 100644
--- a/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java
+++ b/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java
@@ -116,7 +116,8 @@ public void run(final IHookCallBack callBack, ITestResult testResult) {
* @param methodTestLoaders
* @param invocationCount
*/
- protected void run(ITestResult testResult, Method method, List methodTestLoaders, int invocationCount) {
+ protected void run(ITestResult testResult, Method method, List methodTestLoaders,
+ int invocationCount) {
if (citrus == null) {
citrus = Citrus.newInstance(new CitrusSpringContextProvider(applicationContext));
CitrusAnnotations.injectCitrusFramework(this, citrus);
@@ -164,6 +165,9 @@ protected void run(ITestResult testResult, Method method, List metho
@BeforeClass(alwaysRun = true)
public final void before() {
+ // We need to consider the possibility, that one test has meanwhile modified the current citrus instance,
+ // as there can be plenty of tests running between @BeforeSuite and the execution of an actual subclass of
+ // this support. The citrus instance may even have a mocked context.
if (citrus == null) {
citrus = Citrus.newInstance(new CitrusSpringContextProvider(applicationContext));
CitrusAnnotations.injectCitrusFramework(this, citrus);
@@ -206,7 +210,7 @@ public final void beforeSuite() {
CitrusAnnotations.injectCitrusFramework(this, citrus);
beforeSuite(citrus.getCitrusContext());
citrus.beforeSuite(Reporter.getCurrentTestResult().getTestContext().getSuite().getName(),
- Reporter.getCurrentTestResult().getTestContext().getIncludedGroups());
+ Reporter.getCurrentTestResult().getTestContext().getIncludedGroups());
}
/**
diff --git a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java
index 754bb63652..c3dfc33a37 100644
--- a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java
+++ b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonSchemaRepository.java
@@ -16,21 +16,11 @@
package org.citrusframework.json;
-import java.io.IOException;
-import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
-import java.util.Set;
-
-import org.citrusframework.common.InitializingPhase;
-import org.citrusframework.common.Named;
-import org.citrusframework.exceptions.CitrusRuntimeException;
import org.citrusframework.json.schema.SimpleJsonSchema;
-import org.citrusframework.spi.ClasspathResourceResolver;
+import org.citrusframework.repository.BaseRepository;
import org.citrusframework.spi.Resource;
-import org.citrusframework.spi.Resources;
-import org.citrusframework.util.FileUtils;
-import org.citrusframework.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,70 +28,35 @@
* Schema repository holding a set of json schema resources known in the test scope.
* @since 2.7.3
*/
-public class JsonSchemaRepository implements Named, InitializingPhase {
+public class JsonSchemaRepository extends BaseRepository {
- /** This repositories name in the Spring application context */
- private String name;
+ private static final String DEFAULT_NAME = "jsonSchemaRepository";
/** List of schema resources */
private List schemas = new ArrayList<>();
- /** List of location patterns that will be translated to schema resources */
- private List locations = new ArrayList<>();
/** Logger */
private static Logger logger = LoggerFactory.getLogger(JsonSchemaRepository.class);
- @Override
- public void setName(String name) {
- this.name = name;
+ public JsonSchemaRepository() {
+ super(DEFAULT_NAME);
}
- @Override
- public void initialize() {
- try {
- ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver();
- for (String location : locations) {
- Resource found = Resources.create(location);
- if (found.exists()) {
- addSchemas(found);
- } else {
- Set findings;
- if (StringUtils.hasText(FileUtils.getFileExtension(location))) {
- String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*");
- String basePath = FileUtils.getBasePath(location);
- findings = resourceResolver.getResources(basePath, fileNamePattern);
- } else {
- findings = resourceResolver.getResources(location);
- }
-
- for (Path resource : findings) {
- addSchemas(Resources.fromClasspath(resource.toString()));
- }
- }
- }
- } catch (IOException e) {
- throw new CitrusRuntimeException("Failed to initialize Json schema repository", e);
- }
- }
- private void addSchemas(Resource resource) {
+ protected void addRepository(Resource resource) {
if (resource.getLocation().endsWith(".json")) {
if (logger.isDebugEnabled()) {
- logger.debug("Loading json schema resource " + resource.getLocation());
+ logger.debug("Loading json schema resource '{}'", resource.getLocation());
}
SimpleJsonSchema simpleJsonSchema = new SimpleJsonSchema(resource);
simpleJsonSchema.initialize();
schemas.add(simpleJsonSchema);
} else {
- logger.warn("Skipped resource other than json schema for repository (" + resource.getLocation() + ")");
+ logger.warn("Skipped resource other than json schema for repository '{}'", resource.getLocation());
}
}
- public String getName() {
- return name;
- }
-
public List getSchemas() {
return schemas;
}
@@ -118,11 +73,7 @@ public static void setLog(Logger logger) {
JsonSchemaRepository.logger = logger;
}
- public List getLocations() {
- return locations;
- }
-
- public void setLocations(List locations) {
- this.locations = locations;
+ public void addSchema(SimpleJsonSchema simpleJsonSchema) {
+ schemas.add(simpleJsonSchema);
}
}
diff --git a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java
index 6a7ae17db9..90310c7d3b 100644
--- a/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java
+++ b/validation/citrus-validation-json/src/test/java/org/citrusframework/validation/json/schema/JsonSchemaValidationTest.java
@@ -34,6 +34,7 @@
import org.citrusframework.validation.json.report.GraciousProcessingReport;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@@ -58,12 +59,21 @@ public class JsonSchemaValidationTest {
private JsonSchemaValidation fixture;
+ private AutoCloseable mocks;
+
@BeforeMethod
void beforeMethodSetup() {
- MockitoAnnotations.openMocks(this);
+ mocks = MockitoAnnotations.openMocks(this);
fixture = new JsonSchemaValidation(jsonSchemaFilterMock);
}
+ @AfterMethod
+ void afterMethod() throws Exception {
+ if (mocks != null) {
+ mocks.close();
+ }
+ }
+
@Test
public void testValidJsonMessageSuccessfullyValidated() {
// Setup json schema repositories
@@ -264,7 +274,7 @@ public void testJsonSchemaFilterIsCalled() {
@Test
public void testLookup() {
Map> validators = SchemaValidator.lookup();
- assertEquals(validators.size(), 1L);
+ assertEquals(1L, validators.size());
assertNotNull(validators.get("defaultJsonSchemaValidator"));
assertEquals(validators.get("defaultJsonSchemaValidator").getClass(), JsonSchemaValidation.class);
}
diff --git a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java
index e696c6316b..7ca3052066 100644
--- a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java
+++ b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XsdSchemaRepository.java
@@ -17,20 +17,14 @@
package org.citrusframework.xml;
import java.io.IOException;
-import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
-import java.util.Set;
import javax.xml.parsers.ParserConfigurationException;
-
-import org.citrusframework.common.InitializingPhase;
-import org.citrusframework.common.Named;
import org.citrusframework.exceptions.CitrusRuntimeException;
-import org.citrusframework.spi.ClasspathResourceResolver;
+import org.citrusframework.repository.BaseRepository;
import org.citrusframework.spi.Resource;
import org.citrusframework.spi.Resources;
import org.citrusframework.util.FileUtils;
-import org.citrusframework.util.StringUtils;
import org.citrusframework.xml.schema.TargetNamespaceSchemaMappingStrategy;
import org.citrusframework.xml.schema.WsdlXsdSchema;
import org.citrusframework.xml.schema.XsdSchemaMappingStrategy;
@@ -48,22 +42,23 @@
* @author Christoph Deppisch
*/
@SuppressWarnings("unused")
-public class XsdSchemaRepository implements Named, InitializingPhase {
- /** The name of the repository */
- private String name = "schemaRepository";
+public class XsdSchemaRepository extends BaseRepository {
+
+ private static final String DEFAULT_NAME = "schemaRepository";
/** List of schema resources */
private List schemas = new ArrayList<>();
- /** List of location patterns that will be translated to schema resources */
- private List locations = new ArrayList<>();
-
/** Mapping strategy */
private XsdSchemaMappingStrategy schemaMappingStrategy = new TargetNamespaceSchemaMappingStrategy();
/** Logger */
private static final Logger logger = LoggerFactory.getLogger(XsdSchemaRepository.class);
+ public XsdSchemaRepository() {
+ super(DEFAULT_NAME);
+ }
+
/**
* Find the matching schema for document using given schema mapping strategy.
* @param doc the document instance to validate.
@@ -76,28 +71,8 @@ public boolean canValidate(Document doc) {
@Override
public void initialize() {
+ super.initialize();
try {
- ClasspathResourceResolver resourceResolver = new ClasspathResourceResolver();
- for (String location : locations) {
- Resource found = Resources.create(location);
- if (found.exists()) {
- addSchemas(found);
- } else {
- Set findings;
- if (StringUtils.hasText(FileUtils.getFileExtension(location))) {
- String fileNamePattern = FileUtils.getFileName(location).replace(".", "\\.").replace("*", ".*");
- String basePath = FileUtils.getBasePath(location);
- findings = resourceResolver.getResources(basePath, fileNamePattern);
- } else {
- findings = resourceResolver.getResources(location);
- }
-
- for (Path resource : findings) {
- addSchemas(Resources.fromClasspath(resource.toString()));
- }
- }
- }
-
// Add default Citrus message schemas if available on classpath
addCitrusSchema("citrus-http-message");
addCitrusSchema("citrus-mail-message");
@@ -105,7 +80,7 @@ public void initialize() {
addCitrusSchema("citrus-ssh-message");
addCitrusSchema("citrus-rmi-message");
addCitrusSchema("citrus-jmx-message");
- } catch (SAXException | ParserConfigurationException | IOException e) {
+ } catch (SAXException | ParserConfigurationException e) {
throw new CitrusRuntimeException("Failed to initialize Xsd schema repository", e);
}
}
@@ -114,26 +89,26 @@ public void initialize() {
* Adds Citrus message schema to repository if available on classpath.
* @param schemaName The name of the schema within the citrus schema package
*/
- protected void addCitrusSchema(String schemaName) throws IOException, SAXException, ParserConfigurationException {
+ protected void addCitrusSchema(String schemaName) throws SAXException, ParserConfigurationException {
Resource resource = Resources.fromClasspath("classpath:org/citrusframework/schema/" + schemaName + ".xsd");
if (resource.exists()) {
addXsdSchema(resource);
}
}
- private void addSchemas(Resource resource) {
+ protected void addRepository(Resource resource) {
if (resource.getLocation().endsWith(".xsd")) {
addXsdSchema(resource);
} else if (resource.getLocation().endsWith(".wsdl")) {
addWsdlSchema(resource);
} else {
- logger.warn("Skipped resource other than XSD schema for repository (" + resource.getLocation() + ")");
+ logger.warn("Skipped resource other than XSD schema for repository '{}'", resource.getLocation());
}
}
private void addWsdlSchema(Resource resource) {
if (logger.isDebugEnabled()) {
- logger.debug("Loading WSDL schema resource " + resource.getLocation());
+ logger.debug("Loading WSDL schema resource '{}'", resource.getLocation());
}
WsdlXsdSchema wsdl = new WsdlXsdSchema(resource);
@@ -143,7 +118,7 @@ private void addWsdlSchema(Resource resource) {
private void addXsdSchema(Resource resource) {
if (logger.isDebugEnabled()) {
- logger.debug("Loading XSD schema resource " + resource.getLocation());
+ logger.debug("Loading XSD schema resource '{}'", resource.getLocation());
}
SimpleXsdSchema schema = new SimpleXsdSchema(new ByteArrayResource(FileUtils.copyToByteArray(resource)));
@@ -186,33 +161,4 @@ public void setSchemaMappingStrategy(XsdSchemaMappingStrategy schemaMappingStrat
public XsdSchemaMappingStrategy getSchemaMappingStrategy() {
return schemaMappingStrategy;
}
-
- @Override
- public void setName(String name) {
- this.name = name;
- }
-
- /**
- * Gets the name.
- * @return the name to get.
- */
- public String getName() {
- return name;
- }
-
- /**
- * Gets the locations.
- * @return the locations to get.
- */
- public List getLocations() {
- return locations;
- }
-
- /**
- * Sets the locations.
- * @param locations the locations to set
- */
- public void setLocations(List locations) {
- this.locations = locations;
- }
}