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.apicurio apicurio-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 implements RandomElement { + + @Override + public void push(Object value) { + add(value); + } + + @Override + public void push(String key, Object value) { + if (!isEmpty()) { + Object lastElement = get(size() - 1); + if (lastElement instanceof RandomElement randomElement) { + randomElement.push(key, value); + } + } + } + } + + /** + * A random object representing a JSON object, with attributes stored as key-value pairs. Values + * are of type String (simple attributes) or {@link RandomElement}. + */ + class RandomObject extends LinkedHashMap implements RandomElement { + + @Override + public void push(String key, Object value) { + put(key, value); + } + + @Override + public void push(Object value) { + if (value instanceof RandomObject randomObject) { + this.putAll(randomObject); + return; + } + RandomElement.super.push(value); + } + } + + /** + * A random value that either holds a String (simple property) or a random element. + */ + class RandomValue implements RandomElement { + + private Object value; + + public RandomValue() { + } + + public RandomValue(Object value) { + this.value = value; + } + + public Object getValue() { + return value; + } + + @Override + public void push(Object pushedValue) { + if (value instanceof RandomElement randomElement) { + randomElement.push(pushedValue); + } else { + this.value = pushedValue; + } + } + + @Override + public void push(String key, Object pushedValue) { + if (value instanceof RandomElement randomElement) { + randomElement.push(key, pushedValue); + } else { + throw new IllegalStateException("Cannot push key/value to value: " + value); + } + } + + } +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java new file mode 100644 index 0000000000..67b46e8be7 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomEnumGenerator.java @@ -0,0 +1,25 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import java.util.stream.Collectors; + +public class RandomEnumGenerator extends RandomGenerator { + + + @Override + public boolean handles(OasSchema other) { + return other.enum_ != null; + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + List anEnum = schema.enum_; + if (anEnum != null) { + String enumValues = schema.enum_.stream().map(value -> "'" + value + "'") + .collect(Collectors.joining(",")); + randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomEnumValue(%s)".formatted(enumValues)); + } + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java new file mode 100644 index 0000000000..9f74422e48 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGenerator.java @@ -0,0 +1,61 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.Objects; + +/** + * Abstract base class for generators that produce random data based on an OpenAPI schema. + * Subclasses must implement the {@link #generate} method to provide specific random data generation logic. + * + *

The class provides methods for determining if a generator can handle a given schema, + * based on the schema type, format, pattern, and enum constraints. + */ +public abstract class RandomGenerator { + + public static final String ANY = "$ANY$"; + + private final OasSchema schema; + + protected RandomGenerator() { + this.schema = null; + } + + protected RandomGenerator(OasSchema schema) { + this.schema = schema; + } + + public boolean handles(OasSchema other) { + if (other == null || schema == null) { + return false; + } + + if (ANY.equals(schema.type) || Objects.equals(schema.type, other.type)) { + if (schema.format != null) { + return (ANY.equals(schema.format) && other.format != null)|| Objects.equals(schema.format, other.format); + } + + if (schema.pattern != null) { + return (ANY.equals(schema.pattern) && other.pattern != null) || Objects.equals(schema.pattern, other.pattern); + } + + if (schema.enum_ != null && other.enum_ != null) { + return true; + } + + return true; + } + + return false; + } + + abstract void generate(RandomContext randomContext, OasSchema schema); + + public static final RandomGenerator NULL_GENERATOR = new RandomGenerator() { + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + // Do nothing + } + }; + +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java new file mode 100644 index 0000000000..8fbeac3ef4 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomGeneratorBuilder.java @@ -0,0 +1,62 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.Collections; +import java.util.function.BiConsumer; + +/** + * A simple builder for building {@link java.util.random.RandomGenerator}s. + */ +public class RandomGeneratorBuilder { + + private final OasSchema schema = new Oas30Schema(); + + private RandomGeneratorBuilder() { + } + + static RandomGeneratorBuilder builder() { + return new RandomGeneratorBuilder(); + } + + static RandomGeneratorBuilder builder(String type, String format) { + return new RandomGeneratorBuilder().with(type, format); + } + + RandomGeneratorBuilder with(String type, String format) { + schema.type = type; + schema.format = format; + return this; + } + + + RandomGeneratorBuilder withType(String type) { + schema.type = type; + return this; + } + + RandomGeneratorBuilder withFormat(String format) { + schema.format = format; + return this; + } + + RandomGeneratorBuilder withPattern(String pattern) { + schema.pattern = pattern; + return this; + } + + RandomGeneratorBuilder withEnum() { + schema.enum_ = Collections.emptyList(); + return this; + } + + RandomGenerator build(BiConsumer consumer) { + return new RandomGenerator(schema) { + @Override + void generate(RandomContext randomContext, OasSchema schema) { + consumer.accept(randomContext, schema); + } + }; + } + +} \ No newline at end of file diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelBuilder.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelBuilder.java new file mode 100644 index 0000000000..c2ff6bbfea --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelBuilder.java @@ -0,0 +1,135 @@ +/* + * 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.ArrayDeque; +import java.util.Deque; +import org.citrusframework.openapi.random.RandomElement.RandomList; +import org.citrusframework.openapi.random.RandomElement.RandomObject; +import org.citrusframework.openapi.random.RandomElement.RandomValue; + +/** + * RandomModelBuilder is a class for building random JSON models. It supports adding simple values, + * objects, properties, and arrays to the JSON structure. The final model can be converted to a JSON + * string using the `writeToJson` method. I + *

+ * The builder is able to build nested structures and can also handle native string, number, and + * boolean elements, represented as functions for later dynamic string conversion by Citrus. + *

+ * Example usage: + *

+ * RandomModelBuilder builder = new RandomModelBuilder();
+ * builder.object(() -> {
+ *     builder.property("key1", () -> builder.appendSimple("value1"));
+ *     builder.property("key2", () -> builder.array(() -> {
+ *         builder.appendSimple("value2");
+ *         builder.appendSimple("value3");
+ *     }));
+ * });
+ * String json = builder.writeToJson();
+ * 
+ */ +public class RandomModelBuilder { + + final Deque deque = new ArrayDeque<>(); + + private final boolean quote; + + /** + * Creates a {@link RandomModelBuilder} in respective quoting mode. + * Quoting should be activated in case an object is created by the builder. In this case, + * all properties added by respective "quoted" methods, will be quoted. + * + * @param quote whether to run the builder in quoting mode or not. + */ + public RandomModelBuilder(boolean quote) { + deque.push(new RandomValue()); + this.quote = quote; + } + + public String write() { + return RandomModelWriter.toString(this); + } + + /** + * Append the simpleValue as is, no quoting + */ + public void appendSimple(String simpleValue) { + if (deque.isEmpty()) { + deque.push(new RandomValue(simpleValue)); + } else { + deque.peek().push(simpleValue); + } + } + + /** + * If the builder is in quoting mode, the native value will be quoted, otherwise it will be + * added as ist. + *s + * @param simpleValue + */ + public void appendSimpleQuoted(String simpleValue) { + appendSimple(quote(simpleValue)); + } + + public void object(Runnable objectBuilder) { + if (deque.isEmpty()) { + throwIllegalState(); + } + + RandomObject randomObject = new RandomObject(); + deque.peek().push(randomObject); + objectBuilder.run(); + } + + private static void throwIllegalState() { + throw new IllegalStateException("Encountered empty stack!"); + } + + public void property(String key, Runnable valueBuilder) { + if (deque.isEmpty()) { + throwIllegalState(); + } + + RandomValue randomValue = new RandomValue(); + deque.peek().push(key, randomValue); + + deque.push(randomValue); + valueBuilder.run(); + deque.pop(); + } + + public void array(Runnable arrayBuilder) { + if (deque.isEmpty()) { + throwIllegalState(); + } + RandomList randomList = new RandomList(); + deque.peek().push(randomList); + + // For a list, we need to push the list to the queue. This is because when the builder adds elements + // to the list, and we are dealing with nested lists, we can otherwise not distinguish whether to put + // an element into the list or into the nested list. + deque.push(randomList); + arrayBuilder.run(); + deque.pop(); + } + + public String quote(String text) { + return quote ? String.format("\"%s\"", text) : text; + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelWriter.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelWriter.java new file mode 100644 index 0000000000..2c37e621e5 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomModelWriter.java @@ -0,0 +1,115 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.random; + +import static org.citrusframework.util.StringUtils.trimTrailingComma; + +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import org.citrusframework.openapi.random.RandomElement.RandomValue; + +/** + * Utility class for converting a {@link RandomModelBuilder} to its string representation. + * This class provides static methods to serialize the model built by {@link RandomModelBuilder}. + */ +class RandomModelWriter { + + private RandomModelWriter() { + // static access only + } + + static String toString(RandomModelBuilder randomModelBuilder) { + + StringBuilder builder = new StringBuilder(); + appendObject(builder, randomModelBuilder.deque); + return builder.toString(); + } + + private static void appendObject(StringBuilder builder, Object object) { + + if (object instanceof Deque deque) { + while (!deque.isEmpty()) { + appendObject(builder, deque.pop()); + } + return; + } + if (object instanceof Map map) { + //noinspection unchecked + appendMap(builder, (Map) map); + } else if (object instanceof List list) { + appendArray(builder, list); + } else if (object instanceof String string) { + builder.append(string); + } else if (object instanceof RandomValue randomValue) { + appendObject(builder, randomValue.getValue()); + } + } + + private static void appendArray(StringBuilder builder, List list) { + builder.append("["); + list.forEach(listValue -> { + appendObject(builder, listValue); + builder.append(","); + }); + trimTrailingComma(builder); + builder.append("]"); + } + + private static void appendMap(StringBuilder builder, Map map) { + if (map.size() == 1) { + Entry entry = map.entrySet().iterator().next(); + String key = entry.getKey(); + Object value = entry.getValue(); + + if ("ARRAY".equals(key)) { + appendObject(builder, value); + } else if ("NATIVE".equals(key)) { + builder.append(value); + } else { + appendJsonObject(builder, map); + } + } else { + appendJsonObject(builder, map); + } + } + + private static void appendJsonObject(StringBuilder builder, Map map) { + builder.append("{"); + for (Entry entry : map.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + builder.append("\""); + builder.append(key); + builder.append("\": "); + + if (value instanceof String) { + builder.append(value); + } else if (value instanceof Map) { + appendObject(builder, value); + } else if (value instanceof RandomValue randomValue) { + appendObject(builder, randomValue.getValue()); + } + + builder.append(","); + } + trimTrailingComma(builder); + + builder.append("}"); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java new file mode 100644 index 0000000000..a9c0742259 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomNumberGenerator.java @@ -0,0 +1,155 @@ +package org.citrusframework.openapi.random; + +import static java.lang.Boolean.TRUE; +import static java.lang.String.format; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_INTEGER; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.math.BigDecimal; +import org.citrusframework.openapi.util.OpenApiUtils; + +/** + * A generator for producing random numbers based on an OpenAPI schema. This class extends the + * {@link RandomGenerator} and provides a specific implementation for generating random numbers with + * constraints defined in the schema. + * + *

Supported constraints: + *

    + *
  • minimum: The minimum value for the generated number.
  • + *
  • maximum: The maximum value for the generated number.
  • + *
  • exclusiveMinimum: If true, the generated number will be strictly greater than the minimum.
  • + *
  • exclusiveMaximum: If true, the generated number will be strictly less than the maximum.
  • + *
  • multipleOf: The generated number will be a multiple of this value.
  • + *
+ * + *

The generator supports generating numbers for both integer and floating-point types, including + * int32, int64, double, and float. This support + * extends to the multipleOf constraint, ensuring that the generated numbers can be precise + * multiples of the specified value. + * + *

The generator determines the appropriate bounds and constraints based on the provided schema + * and generates a random number accordingly. + */ +public class RandomNumberGenerator extends RandomGenerator { + + public static final BigDecimal THOUSAND = new BigDecimal(1000); + public static final BigDecimal HUNDRED = java.math.BigDecimal.valueOf(100); + public static final BigDecimal MINUS_THOUSAND = new BigDecimal(-1000); + + @Override + public boolean handles(OasSchema other) { + return OpenApiUtils.isAnyNumberScheme(other); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + + boolean exclusiveMaximum = TRUE.equals(schema.exclusiveMaximum); + boolean exclusiveMinimum = TRUE.equals(schema.exclusiveMinimum); + + BigDecimal[] bounds = determineBounds(schema); + + BigDecimal minimum = bounds[0]; + BigDecimal maximum = bounds[1]; + + if (schema.multipleOf != null) { + randomContext.getRandomModelBuilder().appendSimple(format( + "citrus:randomNumberGenerator('%d', '%s', '%s', '%s', '%s', '%s')", + determineDecimalPlaces(schema, minimum, maximum), + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + schema.multipleOf + )); + } else { + randomContext.getRandomModelBuilder().appendSimple(format( + "citrus:randomNumberGenerator('%d', '%s', '%s', '%s', '%s')", + determineDecimalPlaces(schema, minimum, maximum), + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum + )); + } + } + + /** + * Determines the number of decimal places to use based on the given schema and + * minimum/maximum/multipleOf values. For integer types, it returns 0. For other types, it + * returns the maximum number of decimal places found between the minimum and maximum values, + * with a minimum of 2 decimal places. + */ + private int determineDecimalPlaces(OasSchema schema, BigDecimal minimum, + BigDecimal maximum) { + if (TYPE_INTEGER.equals(schema.type)) { + return 0; + } else { + Number multipleOf = schema.multipleOf; + if (multipleOf != null) { + return findLeastSignificantDecimalPlace(new BigDecimal(multipleOf.toString())); + } + + return Math.max(2, Math.max(findLeastSignificantDecimalPlace(minimum), + findLeastSignificantDecimalPlace(maximum))); + + } + } + + /** + * Determine some reasonable bounds for a random number + */ + private static BigDecimal[] determineBounds(OasSchema schema) { + Number maximum = schema.maximum; + Number minimum = schema.minimum; + Number multipleOf = schema.multipleOf; + + BigDecimal bdMinimum; + BigDecimal bdMaximum; + + if (minimum == null && maximum == null) { + bdMinimum = MINUS_THOUSAND; + bdMaximum = THOUSAND; + } else if (minimum == null) { + bdMaximum = new BigDecimal(maximum.toString()); + bdMinimum = calculateMinRelativeToMax(bdMaximum, multipleOf); + } else if (maximum == null) { + bdMinimum = new BigDecimal(minimum.toString()); + bdMaximum = calculateMaxRelativeToMin(bdMinimum, multipleOf); + } else { + bdMinimum = new BigDecimal(minimum.toString()); + bdMaximum = new BigDecimal(maximum.toString()); + } + + return new BigDecimal[]{bdMinimum, bdMaximum}; + } + + static BigDecimal calculateMinRelativeToMax(BigDecimal max, Number multipleOf) { + if (multipleOf != null) { + return max.subtract(new BigDecimal(multipleOf.toString()).abs().multiply(HUNDRED)); + } else { + return max.subtract(max.multiply(BigDecimal.valueOf(2)).max(THOUSAND)); + } + } + + static BigDecimal calculateMaxRelativeToMin(BigDecimal min, Number multipleOf) { + if (multipleOf != null) { + return min.add(new BigDecimal(multipleOf.toString()).abs().multiply(HUNDRED)); + } else { + return min.add(min.multiply(BigDecimal.valueOf(2)).max(THOUSAND)); + } + } + + int findLeastSignificantDecimalPlace(BigDecimal number) { + number = number.stripTrailingZeros(); + + String[] parts = number.toPlainString().split("\\."); + + if (parts.length == 1) { + return 0; + } + + return parts[1].length(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java new file mode 100644 index 0000000000..8b7ef2b9d4 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomObjectGenerator.java @@ -0,0 +1,58 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import org.citrusframework.openapi.OpenApiConstants; +import org.citrusframework.openapi.util.OpenApiUtils; + +/** + * A generator for producing random objects based on an OpenAPI schema. This class extends + * the {@link RandomGenerator} and provides a specific implementation for generating objects + * with properties defined in the schema. + * + *

The generator supports object schemas and prevents recursion by keeping track of the + * schemas being processed.

+ */ +public class RandomObjectGenerator extends RandomGenerator { + + private static final String OBJECT_STACK = "OBJECT_STACK"; + + private static final OasSchema OBJECT_SCHEMA = new Oas30Schema(); + + static { + OBJECT_SCHEMA.type = OpenApiConstants.TYPE_OBJECT; + } + + public RandomObjectGenerator() { + super(OBJECT_SCHEMA); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + + Deque objectStack = randomContext.get(OBJECT_STACK, k -> new ArrayDeque<>()); + + if (objectStack.contains(schema)) { + // If we have already created this schema, we are very likely in a recursion and need to stop. + return; + } + + objectStack.push(schema); + randomContext.getRandomModelBuilder().object(() -> { + if (schema.properties != null) { + for (Map.Entry entry : schema.properties.entrySet()) { + if (randomContext.getSpecification().isGenerateOptionalFields() || OpenApiUtils.isRequired(schema, + entry.getKey())) { + randomContext.getRandomModelBuilder().property(entry.getKey(), () -> + randomContext.generate(entry.getValue())); + } + } + } + }); + objectStack.pop(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java new file mode 100644 index 0000000000..cc30c220eb --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/random/RandomStringGenerator.java @@ -0,0 +1,39 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.citrusframework.openapi.OpenApiConstants; + +/** + * A generator for producing random strings based on an OpenAPI schema. + * This class extends the {@link RandomGenerator} and provides a specific implementation + * for generating random strings with constraints defined in the schema. + */ +public class RandomStringGenerator extends RandomGenerator { + + private static final OasSchema STRING_SCHEMA = new Oas30Schema(); + + static { + STRING_SCHEMA.type = OpenApiConstants.TYPE_STRING; + } + + public RandomStringGenerator() { + super(STRING_SCHEMA); + } + + @Override + void generate(RandomContext randomContext, OasSchema schema) { + int min = 1; + int max = 10; + + if (schema.minLength != null && schema.minLength.intValue() > 0) { + min = schema.minLength.intValue(); + } + + if (schema.maxLength != null && schema.maxLength.intValue() > 0) { + max = schema.maxLength.intValue(); + } + + randomContext.getRandomModelBuilder().appendSimpleQuoted("citrus:randomString(%s,MIXED,true,%s)".formatted(max, min)); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java new file mode 100644 index 0000000000..ae4008111a --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/util/OpenApiUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.util; + +import static java.lang.String.format; + +import io.apicurio.datamodels.openapi.models.OasOperation; +import io.apicurio.datamodels.openapi.models.OasSchema; +import jakarta.annotation.Nonnull; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.OpenApiConstants; +import org.citrusframework.util.StringUtils; + +public class OpenApiUtils { + + private OpenApiUtils() { + // Static access only + } + + public static String getMethodPath(@Nonnull HttpMessage httpMessage) { + Object methodHeader = httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_METHOD); + Object path = httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI); + + return getMethodPath(methodHeader != null ? methodHeader.toString().toLowerCase() : "null", + path != null ? path.toString() : "null"); + } + + public static String getMethodPath(@Nonnull String method, @Nonnull String path) { + if (StringUtils.hasText(path) && path.startsWith("/")) { + path = path.substring(1); + } + return format("/%s/%s", method.toLowerCase(), path); + } + + /** + * @return a unique scenario id for the {@link OasOperation} + */ + public static String createFullPathOperationIdentifier(String path, OasOperation oasOperation) { + return format("%s_%s", oasOperation.getMethod().toUpperCase(), path); + } + + public static boolean isAnyNumberScheme(OasSchema schema) { + return ( + schema != null && + (OpenApiConstants.TYPE_INTEGER.equalsIgnoreCase(schema.type) || + OpenApiConstants.TYPE_NUMBER.equalsIgnoreCase(schema.type)) + ); + } + + /** + * Checks if given field name is in list of required fields for this schema. + */ + public static boolean isRequired(OasSchema schema, String field) { + if (schema.required == null) { + return true; + } + + return schema.required.contains(field); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java new file mode 100644 index 0000000000..cb14d44c89 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessor.java @@ -0,0 +1,57 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.validation.ValidationProcessor; + +/** + * {@code ValidationProcessor} that facilitates the use of Atlassian's Swagger Request Validator, + * and delegates validation of OpenApi requests to instances of {@link OpenApiRequestValidator}. + */ +public class OpenApiRequestValidationProcessor implements + ValidationProcessor { + + private final OpenApiSpecification openApiSpecification; + + private final String operationId; + + private final OpenApiRequestValidator openApiRequestValidator; + + public OpenApiRequestValidationProcessor(OpenApiSpecification openApiSpecification, + String operationId) { + this.openApiSpecification = openApiSpecification; + this.operationId = operationId; + this.openApiRequestValidator = new OpenApiRequestValidator(openApiSpecification); + } + + @Override + public void validate(Message message, TestContext context) { + + if (!(message instanceof HttpMessage httpMessage)) { + return; + } + + openApiSpecification.getOperation( + operationId, context).ifPresent(operationPathAdapter -> + openApiRequestValidator.validateRequest(operationPathAdapter, httpMessage)); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java new file mode 100644 index 0000000000..94aa08ae9a --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiRequestValidator.java @@ -0,0 +1,97 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.report.ValidationReport; +import java.util.ArrayList; +import java.util.Collection; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.http.message.HttpMessageUtils; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; + +/** + * Specific validator that uses atlassian and is responsible for validating HTTP requests + * against an OpenAPI specification using the provided {@code OpenApiInteractionValidator}. + */ +public class OpenApiRequestValidator extends OpenApiValidator { + + public OpenApiRequestValidator(OpenApiSpecification openApiSpecification) { + super(openApiSpecification); + setEnabled(openApiSpecification.getSwaggerOpenApiValidationContext() != null && openApiSpecification.getSwaggerOpenApiValidationContext().isRequestValidationEnabled()); + } + + @Override + protected String getType() { + return "request"; + } + + public void validateRequest(OperationPathAdapter operationPathAdapter, + HttpMessage requestMessage) { + + if (enabled && openApiInteractionValidator != null) { + ValidationReport validationReport = openApiInteractionValidator.validateRequest( + createRequestFromMessage(operationPathAdapter, requestMessage)); + if (validationReport.hasErrors()) { + throw new ValidationException( + constructErrorMessage(operationPathAdapter, validationReport)); + } + } + } + + Request createRequestFromMessage(OperationPathAdapter operationPathAdapter, + HttpMessage httpMessage) { + var payload = httpMessage.getPayload(); + + String contextPath = operationPathAdapter.contextPath(); + String requestUri = (String) httpMessage.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI); + if (contextPath != null && requestUri.startsWith(contextPath)) { + requestUri = requestUri.substring(contextPath.length()); + } + + SimpleRequest.Builder requestBuilder = new SimpleRequest.Builder( + httpMessage.getRequestMethod().asHttpMethod().name(), requestUri + ); + + if (payload != null) { + requestBuilder = requestBuilder.withBody(payload.toString()); + } + + SimpleRequest.Builder finalRequestBuilder = requestBuilder; + finalRequestBuilder.withAccept(httpMessage.getAccept()); + + HttpMessageUtils.getQueryParameterMap(httpMessage) + .forEach((key, value) -> finalRequestBuilder.withQueryParam(key, new ArrayList<>( + value))); + + httpMessage.getHeaders().forEach((key, value) -> { + if (value instanceof Collection collection) { + collection.forEach( v -> finalRequestBuilder.withHeader(key, v != null ? v.toString() : null)); + } else { + finalRequestBuilder.withHeader(key, + value != null ? value.toString() : null); + } + }); + + return requestBuilder.build(); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java new file mode 100644 index 0000000000..c098fda6a0 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessor.java @@ -0,0 +1,57 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.validation.ValidationProcessor; + +/** + * {@code ValidationProcessor} that delegates validation of OpenApi responses to instances of + * {@link OpenApiResponseValidator}. + */ +public class OpenApiResponseValidationProcessor implements + ValidationProcessor { + + private final OpenApiSpecification openApiSpecification; + + private final String operationId; + + private final OpenApiResponseValidator openApiResponseValidator; + + public OpenApiResponseValidationProcessor(OpenApiSpecification openApiSpecification, + String operationId) { + this.operationId = operationId; + this.openApiSpecification = openApiSpecification; + this.openApiResponseValidator = new OpenApiResponseValidator(openApiSpecification); + } + + @Override + public void validate(Message message, TestContext context) { + + if (!(message instanceof HttpMessage httpMessage)) { + return; + } + + openApiSpecification.getOperation( + operationId, context).ifPresent(operationPathAdapter -> + openApiResponseValidator.validateResponse(operationPathAdapter, httpMessage)); + } + +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java new file mode 100644 index 0000000000..faefe24a9b --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiResponseValidator.java @@ -0,0 +1,76 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.model.Request.Method; +import com.atlassian.oai.validator.model.Response; +import com.atlassian.oai.validator.model.SimpleResponse; +import com.atlassian.oai.validator.report.ValidationReport; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.springframework.http.HttpStatusCode; + +/** + * Specific validator, that facilitates the use of Atlassian's Swagger Request Validator, + * and delegates validation of OpenApi requests to instances of {@link OpenApiRequestValidator}. + */ +public class OpenApiResponseValidator extends OpenApiValidator { + + public OpenApiResponseValidator(OpenApiSpecification openApiSpecification) { + super(openApiSpecification); + setEnabled(openApiSpecification.getSwaggerOpenApiValidationContext() != null && openApiSpecification.getSwaggerOpenApiValidationContext().isResponseValidationEnabled()); + } + + @Override + protected String getType() { + return "response"; + } + + public void validateResponse(OperationPathAdapter operationPathAdapter, HttpMessage httpMessage) { + + if (enabled && openApiInteractionValidator != null) { + HttpStatusCode statusCode = httpMessage.getStatusCode(); + Response response = createResponseFromMessage(httpMessage, + statusCode != null ? statusCode.value() : null); + + ValidationReport validationReport = openApiInteractionValidator.validateResponse( + operationPathAdapter.apiPath(), + Method.valueOf(operationPathAdapter.operation().getMethod().toUpperCase()), + response); + if (validationReport.hasErrors()) { + throw new ValidationException(constructErrorMessage(operationPathAdapter, validationReport)); + } + } + } + + Response createResponseFromMessage(HttpMessage message, Integer statusCode) { + var payload = message.getPayload(); + SimpleResponse.Builder responseBuilder = new SimpleResponse.Builder(statusCode); + + if (payload != null) { + responseBuilder = responseBuilder.withBody(payload.toString()); + } + + SimpleResponse.Builder finalResponseBuilder = responseBuilder; + message.getHeaders().forEach((key, value) -> finalResponseBuilder.withHeader(key, + value != null ? value.toString() : null)); + + return responseBuilder.build(); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java new file mode 100644 index 0000000000..c5393f8051 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/OpenApiValidator.java @@ -0,0 +1,67 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.report.ValidationReport; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; + +public abstract class OpenApiValidator { + + protected final OpenApiInteractionValidator openApiInteractionValidator; + + protected boolean enabled; + + protected OpenApiValidator(OpenApiSpecification openApiSpecification) { + SwaggerOpenApiValidationContext swaggerOpenApiValidationContext = openApiSpecification.getSwaggerOpenApiValidationContext(); + if (swaggerOpenApiValidationContext != null) { + openApiInteractionValidator = openApiSpecification.getSwaggerOpenApiValidationContext() + .getOpenApiInteractionValidator(); + } else { + openApiInteractionValidator = null; + } + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + protected abstract String getType(); + + /** + * Constructs the error message of a failed validation based on the processing report passed + * from {@link ValidationReport}. + * + * @param report The report containing the error message + * @return A string representation of all messages contained in the report + */ + protected String constructErrorMessage(OperationPathAdapter operationPathAdapter, + ValidationReport report) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("OpenApi "); + stringBuilder.append(getType()); + stringBuilder.append(" validation failed for operation: "); + stringBuilder.append(operationPathAdapter); + report.getMessages().forEach(message -> stringBuilder.append("\n\t").append(message)); + return stringBuilder.toString(); + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java new file mode 100644 index 0000000000..ad9dbcf23e --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContext.java @@ -0,0 +1,77 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.citrusframework.openapi.OpenApiSettings.isRequestValidationEnabledGlobally; +import static org.citrusframework.openapi.OpenApiSettings.isResponseValidationEnabledGlobally; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.report.MessageResolver; +import com.atlassian.oai.validator.schema.SchemaValidator; +import com.atlassian.oai.validator.schema.SwaggerV20Library; +import io.swagger.v3.oas.models.OpenAPI; + +public class SwaggerOpenApiValidationContext { + + private final OpenAPI openApi; + + private OpenApiInteractionValidator openApiInteractionValidator; + + private SchemaValidator schemaValidator; + + private boolean responseValidationEnabled = isResponseValidationEnabledGlobally(); + + private boolean requestValidationEnabled = isRequestValidationEnabledGlobally(); + + public SwaggerOpenApiValidationContext(OpenAPI openApi) { + this.openApi = openApi; + } + + public OpenAPI getSwaggerOpenApi() { + return openApi; + } + + public synchronized OpenApiInteractionValidator getOpenApiInteractionValidator() { + if (openApiInteractionValidator == null) { + openApiInteractionValidator = new OpenApiInteractionValidator.Builder().withApi(openApi).build(); + } + return openApiInteractionValidator; + } + + public synchronized SchemaValidator getSchemaValidator() { + if (schemaValidator == null) { + schemaValidator = new SchemaValidator(openApi, new MessageResolver(), SwaggerV20Library::schemaFactory); + } + return schemaValidator; + } + + public boolean isResponseValidationEnabled() { + return responseValidationEnabled; + } + + public void setResponseValidationEnabled(boolean responseValidationEnabled) { + this.responseValidationEnabled = responseValidationEnabled; + } + + public boolean isRequestValidationEnabled() { + return requestValidationEnabled; + } + + public void setRequestValidationEnabled(boolean requestValidationEnabled) { + this.requestValidationEnabled = requestValidationEnabled; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java new file mode 100644 index 0000000000..51d0ba4412 --- /dev/null +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/validation/SwaggerOpenApiValidationContextLoader.java @@ -0,0 +1,78 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import com.atlassian.oai.validator.OpenApiInteractionValidator.SpecSource; +import com.atlassian.oai.validator.util.OpenApiLoader; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.core.models.ParseOptions; +import jakarta.annotation.Nonnull; +import java.net.URL; +import java.util.Collections; +import org.citrusframework.openapi.OpenApiResourceLoader; +import org.citrusframework.spi.Resource; + +/** + * Utility class for loading Swagger OpenAPI specifications from various resources. + */ +public abstract class SwaggerOpenApiValidationContextLoader { + + private SwaggerOpenApiValidationContextLoader() { + // Static access only + } + /** + * Loads an OpenAPI specification from a secured web resource. + * + * @param url the URL of the secured web resource + * @return the loaded OpenAPI specification + */ + public static SwaggerOpenApiValidationContext fromSecuredWebResource(@Nonnull URL url) { + return createValidationContext(new OpenApiLoader().loadApi(SpecSource.inline(OpenApiResourceLoader.rawFromSecuredWebResource(url)), Collections.emptyList(), defaultParseOptions())); + } + + /** + * Loads an OpenAPI specification from a web resource. + * + * @param url the URL of the web resource + * @return the loaded OpenAPI specification + */ + public static SwaggerOpenApiValidationContext fromWebResource(@Nonnull URL url) { + return createValidationContext(new OpenApiLoader().loadApi(SpecSource.inline(OpenApiResourceLoader.rawFromWebResource(url)), Collections.emptyList(), defaultParseOptions())); + } + + /** + * Loads an OpenAPI specification from a file. + * + * @param resource the file resource containing the OpenAPI specification + * @return the loaded OpenAPI specification + */ + public static SwaggerOpenApiValidationContext fromFile(@Nonnull Resource resource) { + return createValidationContext(new OpenApiLoader().loadApi(SpecSource.inline(OpenApiResourceLoader.rawFromFile(resource)), Collections.emptyList(), defaultParseOptions())); + } + + private static SwaggerOpenApiValidationContext createValidationContext(OpenAPI openApi) { + return new SwaggerOpenApiValidationContext(openApi); + } + + private static ParseOptions defaultParseOptions() { + final ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setResolveFully(true); + parseOptions.setResolveCombinators(false); + return parseOptions; + } +} diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java index dfd837655a..96885ae516 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/xml/OpenApi.java @@ -16,10 +16,6 @@ package org.citrusframework.openapi.xml; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlAttribute; @@ -46,6 +42,10 @@ import org.citrusframework.xml.actions.Receive; import org.citrusframework.xml.actions.Send; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + /** * @author Christoph Deppisch */ diff --git a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java index 59f92bd9ff..21a02a1950 100644 --- a/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java +++ b/connectors/citrus-openapi/src/main/java/org/citrusframework/openapi/yaml/OpenApi.java @@ -16,10 +16,6 @@ package org.citrusframework.openapi.yaml; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - import org.citrusframework.TestAction; import org.citrusframework.TestActionBuilder; import org.citrusframework.actions.ReceiveMessageAction; @@ -36,9 +32,13 @@ import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; import org.citrusframework.spi.ReferenceResolver; import org.citrusframework.spi.ReferenceResolverAware; +import org.citrusframework.yaml.actions.Message; import org.citrusframework.yaml.actions.Receive; import org.citrusframework.yaml.actions.Send; -import org.citrusframework.yaml.actions.Message; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; /** * @author Christoph Deppisch diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java new file mode 100644 index 0000000000..d837f1f0ee --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiPathRegistryTest.java @@ -0,0 +1,172 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +public class OpenApiPathRegistryTest { + + private static final String[] SEGMENTS = {"api", "v1", "pet", "user", "order", "product", + "category", "service", "data"}; + private static final String VARIABLE_TEMPLATE = "{%s}"; + private static final String[] VARIABLES = {"id", "userId", "orderId", "productId", + "categoryId"}; + + public static List generatePaths(int numberOfPaths) { + List paths = new ArrayList<>(); + Random random = new Random(); + + Set allGenerated = new HashSet<>(); + while (allGenerated.size() < numberOfPaths) { + int numberOfSegments = 1 + random.nextInt(7); // 1 to 7 segments + StringBuilder pathBuilder = new StringBuilder("/api/v1"); + + int nids = 0; + for (int j = 0; j < numberOfSegments; j++) { + if (nids < 2 && nids < numberOfSegments - 1 && random.nextBoolean()) { + nids++; + // Add a segment with a variable + pathBuilder.append("/").append(String.format(VARIABLE_TEMPLATE, + VARIABLES[random.nextInt(VARIABLES.length)])); + } else { + // Add a fixed segment + pathBuilder.append("/").append(SEGMENTS[random.nextInt(SEGMENTS.length)]); + } + } + + String path = pathBuilder.toString(); + if (!allGenerated.contains(path)) { + paths.add(path); + allGenerated.add(path); + } + } + return paths; + } + + @Test + public void insertShouldSucceedOnSameValue() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root")); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root")); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root"); + } + + @Test + public void insertShouldFailOnSamePathWithDifferentValue() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root1")); + assertFalse(openApiPathRegistry.insert("/s1/s2", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root1"); + } + + @Test + public void searchShouldSucceedOnPartialPathMatchWithDifferentVariables() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}", "root1")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id2}/s4/{id1}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2/1111"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/123/s4/222"), "root2"); + + openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id2}", "root1")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}/s4/{id2}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2/1111"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/123/s4/222"), "root2"); + } + + @Test + public void insertShouldFailOnMatchingPathWithDifferentValue() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2", "root1")); + assertFalse(openApiPathRegistry.insert("/s1/{id1}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root1"); + assertNull(openApiPathRegistry.search("/s1/111")); + + assertTrue(openApiPathRegistry.insert("/s1/s2/s3/{id2}", "root3")); + assertFalse(openApiPathRegistry.insert("/s1/{id1}/s3/{id2}", "root4")); + assertEquals(openApiPathRegistry.search("/s1/s2/s3/123"), "root3"); + assertEquals(openApiPathRegistry.search("/s1/s2/s3/456"), "root3"); + assertNull(openApiPathRegistry.search("/s1/111/s3/111")); + + openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/{id1}", "root2")); + assertFalse(openApiPathRegistry.insert("/s1/s2", "root1")); + assertEquals(openApiPathRegistry.search("/s1/111"), "root2"); + assertEquals(openApiPathRegistry.search("/s1/s2"), "root2"); + + assertTrue(openApiPathRegistry.insert("/s1/{id1}/s3/{id2}", "root3")); + assertFalse(openApiPathRegistry.insert("/s1/s2/s3/{id2}", "root4")); + assertEquals(openApiPathRegistry.search("/s1/5678/s3/1234"), "root3"); + assertEquals(openApiPathRegistry.search("/s1/s2/s3/1234"), "root3"); + } + + @Test + public void insertShouldNotOverwriteNested() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}", "root1")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}/s3/{id2}", "root2")); + assertEquals(openApiPathRegistry.search("/s1/s2/123"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/1233/s3/121"), "root2"); + + openApiPathRegistry = new OpenApiPathRegistry<>(); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}/s3/{id2}", "root2")); + assertTrue(openApiPathRegistry.insert("/s1/s2/{id1}", "root1")); + assertEquals(openApiPathRegistry.search("/s1/s2/123"), "root1"); + assertEquals(openApiPathRegistry.search("/s1/s2/1233/s3/121"), "root2"); + } + + @Test + public void randomAccess() { + OpenApiPathRegistry openApiPathRegistry = new OpenApiPathRegistry<>(); + + int numberOfPaths = 1000; // Specify the number of paths you want to generate + List paths = generatePaths(numberOfPaths); + + Map pathToValueMap = paths.stream() + .collect(Collectors.toMap(path -> path, k -> k.replaceAll("\\{[a-zA-Z]*}", "1111"))); + paths.removeIf(path -> !openApiPathRegistry.insert(path, pathToValueMap.get(path))); + + Random random = new Random(); + int[] indexes = new int[1000]; + for (int i = 0; i < 1000; i++) { + indexes[i] = random.nextInt(paths.size() - 1); + } + + for (int i = 0; i < 1000; i++) { + String path = paths.get(indexes[i]); + String realPath = pathToValueMap.get(path); + String result = openApiPathRegistry.search(realPath); + Assert.assertNotNull(result, + "No result for real path " + realPath + " expected a match by path " + path); + Assert.assertEquals(result, realPath); + } + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java new file mode 100644 index 0000000000..fe61791bf2 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiRepositoryTest.java @@ -0,0 +1,121 @@ +/* + * 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.Resource; +import org.testng.annotations.Test; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Optional; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +public class OpenApiRepositoryTest { + + private static final String ROOT = "/root"; + + @Test + public void shouldInitializeOpenApiRepository() { + OpenApiRepository openApiRepository = new OpenApiRepository(); + openApiRepository.setRootContextPath(ROOT); + openApiRepository.setLocations( + List.of("org/citrusframework/openapi/petstore/petstore**.json")); + openApiRepository.initialize(); + + List openApiSpecifications = openApiRepository.getOpenApiSpecifications(); + + assertEquals(openApiRepository.getRootContextPath(), ROOT); + assertNotNull(openApiSpecifications); + assertEquals(openApiSpecifications.size(), 3); + + assertEquals(openApiSpecifications.get(0).getRootContextPath(), ROOT); + assertEquals(openApiSpecifications.get(1).getRootContextPath(), ROOT); + + assertTrue( + SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(0))); + assertTrue( + SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(1))); + assertTrue( + SampleOpenApiProcessor.processedSpecifications.contains(openApiSpecifications.get(2))); + } + + @Test + public void shouldResolveResourceAliasFromFile() { + File fileMock = mock(); + doReturn("MyApi.json").when(fileMock).getName(); + Resource resourceMock = mock(); + doReturn(fileMock).when(resourceMock).getFile(); + + Optional alias = OpenApiRepository.determineResourceAlias(resourceMock); + assertTrue(alias.isPresent()); + assertEquals(alias.get(), "MyApi.json"); + } + + @Test + public void shouldResolveResourceAliasFromUrl() throws MalformedURLException { + URL urlMock = mock(); + doReturn("/C:/segment1/segment2/MyApi.json").when(urlMock).getPath(); + Resource resourceMock = mock(); + doThrow(new RuntimeException("Forced Exception")).when(resourceMock).getFile(); + doReturn(urlMock).when(resourceMock).getURL(); + + Optional alias = OpenApiRepository.determineResourceAlias(resourceMock); + assertTrue(alias.isPresent()); + assertEquals(alias.get(), "MyApi.json"); + } + + @Test + public void shouldSetAndProvideProperties() { + // Given + OpenApiRepository openApiRepository = new OpenApiRepository(); + + // When + openApiRepository.setResponseValidationEnabled(true); + openApiRepository.setRequestValidationEnabled(true); + openApiRepository.setRootContextPath("/root"); + openApiRepository.setLocations(List.of("l1", "l2")); + + // Then + assertTrue(openApiRepository.isResponseValidationEnabled()); + assertTrue(openApiRepository.isRequestValidationEnabled()); + assertEquals(openApiRepository.getRootContextPath(), "/root"); + assertEquals(openApiRepository.getLocations(), List.of("l1", "l2")); + + // When + openApiRepository.setResponseValidationEnabled(false); + openApiRepository.setRequestValidationEnabled(false); + openApiRepository.setRootContextPath("/otherRoot"); + openApiRepository.setLocations(List.of("l3", "l4")); + + // Then + assertFalse(openApiRepository.isResponseValidationEnabled()); + assertFalse(openApiRepository.isRequestValidationEnabled()); + assertEquals(openApiRepository.getRootContextPath(), "/otherRoot"); + assertEquals(openApiRepository.getLocations(), List.of("l3", "l4")); + + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java new file mode 100644 index 0000000000..41f612b495 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSettingsTest.java @@ -0,0 +1,212 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; + +import static org.citrusframework.openapi.OpenApiSettings.GENERATE_OPTIONAL_FIELDS_PROPERTY; +import static org.citrusframework.openapi.OpenApiSettings.REQUEST_VALIDATION_ENABLED_PROPERTY; +import static org.citrusframework.openapi.OpenApiSettings.RESPONSE_VALIDATION_ENABLED_PROPERTY; +import static org.citrusframework.openapi.OpenApiSettings.VALIDATE_OPTIONAL_FIELDS_ENV; +import static org.citrusframework.openapi.OpenApiSettings.VALIDATE_OPTIONAL_FIELDS_PROPERTY; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +public class OpenApiSettingsTest { + + private final EnvironmentVariables environmentVariables = new EnvironmentVariables(); + + private static final boolean REQUEST_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isRequestValidationEnabledGlobally(); + + private static final boolean RESPONSE_VALIDATION_ENABLED_GLOBALLY = OpenApiSettings.isResponseValidationEnabledGlobally(); + + private static final boolean VALIDATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY = OpenApiSettings.isValidateOptionalFieldsGlobally(); + + private static final boolean GENERATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY = OpenApiSettings.isGenerateOptionalFieldsGlobally(); + + @BeforeMethod + public void beforeMethod() { + System.clearProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY); + System.clearProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY); + System.clearProperty(REQUEST_VALIDATION_ENABLED_PROPERTY); + System.clearProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY); + } + + @AfterMethod + public void afterMethod() throws Exception { + environmentVariables.teardown(); + + if (!GENERATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY) { + System.clearProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY); + } else { + System.setProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, "true"); + } + + if (!VALIDATE_OPTIONAL_FIELDS_ENABLED_GLOBALLY) { + System.clearProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY); + } else { + System.setProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, "true"); + } + + if (!REQUEST_VALIDATION_ENABLED_GLOBALLY) { + System.clearProperty(REQUEST_VALIDATION_ENABLED_PROPERTY); + } else { + System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "true"); + } + + if (!RESPONSE_VALIDATION_ENABLED_GLOBALLY) { + System.clearProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY); + } else { + System.setProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY, "true"); + } + } + + @Test + public void testRequestValidationEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "true"); + assertTrue(OpenApiSettings.isRequestValidationEnabledGlobally()); + } + + @Test + public void testRequestValidationDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(REQUEST_VALIDATION_ENABLED_PROPERTY, "false"); + assertFalse(OpenApiSettings.isRequestValidationEnabledGlobally()); + } + + @Test + public void testRequestValidationEnabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.REQUEST_VALIDATION_ENABLED_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isRequestValidationEnabledGlobally()); + } + + @Test + public void testRequestValidationDisabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.REQUEST_VALIDATION_ENABLED_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isRequestValidationEnabledGlobally()); + } + + @Test + public void testRequestValidationEnabledByDefault() { + assertTrue(OpenApiSettings.isRequestValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY, "true"); + assertTrue(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(RESPONSE_VALIDATION_ENABLED_PROPERTY, "false"); + assertFalse(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationEnabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.RESPONSE_VALIDATION_ENABLED_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationDisabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.RESPONSE_VALIDATION_ENABLED_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testResponseValidationEnabledByDefault() { + assertTrue(OpenApiSettings.isResponseValidationEnabledGlobally()); + } + + @Test + public void testGenerateOptionalFieldsEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, "true"); + assertTrue(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(GENERATE_OPTIONAL_FIELDS_PROPERTY, "false"); + assertFalse(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsEnabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.GENERATE_OPTIONAL_FIELDS_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsDisabledByEnvVar() throws Exception { + environmentVariables.set(OpenApiSettings.GENERATE_OPTIONAL_FIELDS_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testGenerateOptionalFieldsEnabledByDefault() { + assertTrue(OpenApiSettings.isGenerateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsEnabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, "true"); + assertTrue(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsDisabledByProperty() throws Exception { + environmentVariables.setup(); + System.setProperty(VALIDATE_OPTIONAL_FIELDS_PROPERTY, "false"); + assertFalse(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsEnabledByEnvVar() throws Exception { + environmentVariables.set(VALIDATE_OPTIONAL_FIELDS_ENV, "true"); + environmentVariables.setup(); + assertTrue(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsDisabledByEnvVar() throws Exception { + environmentVariables.set(VALIDATE_OPTIONAL_FIELDS_ENV, "false"); + environmentVariables.setup(); + assertFalse(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } + + @Test + public void testValidateOptionalFieldsEnabledByDefault() { + assertTrue(OpenApiSettings.isValidateOptionalFieldsGlobally()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java new file mode 100644 index 0000000000..65c1433bc4 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationAdapterTest.java @@ -0,0 +1,49 @@ +package org.citrusframework.openapi; + +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +public class OpenApiSpecificationAdapterTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private Object entityMock; + + private OpenApiSpecificationAdapter openApiSpecificationAdapter; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void setUp() { + mockCloseable = MockitoAnnotations.openMocks(this); + openApiSpecificationAdapter = new OpenApiSpecificationAdapter<>(openApiSpecificationMock, entityMock); + } + + @AfterMethod + public void tearDown() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldProvideOpenApiSpecification() { + OpenApiSpecification specification = openApiSpecificationAdapter.openApiSpecification(); + assertNotNull(specification, "OpenApiSpecification should not be null"); + assertEquals(specification, openApiSpecificationMock, "OpenApiSpecification should match the mock"); + } + + @Test + public void shouldProvideEntity() { + Object entity = openApiSpecificationAdapter.entity(); + assertNotNull(entity, "Entity should not be null"); + assertEquals(entity, entityMock, "Entity should match the mock"); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java new file mode 100644 index 0000000000..668ecb9b64 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiSpecificationTest.java @@ -0,0 +1,386 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import io.apicurio.datamodels.openapi.models.OasDocument; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.http.client.HttpClient; +import org.citrusframework.http.client.HttpEndpointConfiguration; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.citrusframework.spi.ReferenceResolver; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources.ClasspathResource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import javax.net.ssl.HttpsURLConnection; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import static org.citrusframework.util.FileUtils.readToString; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.Mockito.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +public class OpenApiSpecificationTest { + + private static final String PING_API_HTTP_URL_STRING = "http://org.citrus.example.com/ping-api.yaml"; + + private static final String PING_API_HTTPS_URL_STRING = "https://org.citrus.example.com/ping-api.yaml"; + + private static final String PING_OPERATION_ID = "doPing"; + + private static final String PONG_OPERATION_ID = "doPong"; + + private static String PING_API_STRING; + + @Mock + private TestContext testContextMock; + + @Mock + private HttpClient httpClient; + + @Mock + private ReferenceResolver referenceResolverMock; + + @Mock + private HttpEndpointConfiguration endpointConfigurationMock; + + private AutoCloseable mockCloseable; + + @InjectMocks + private OpenApiSpecification openApiSpecification; + + @BeforeClass + public void beforeClass() throws IOException { + PING_API_STRING = readToString( + new ClasspathResource( + "classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + } + @BeforeMethod + public void setUp() { + + mockCloseable = MockitoAnnotations.openMocks(this); + + testContextMock.setReferenceResolver(referenceResolverMock); + } + + @AfterMethod + public void tearDown() throws Exception { + mockCloseable.close(); + } + + @DataProvider(name = "protocollDataProvider") + public static Object[][] protocolls() { + return new Object[][] {{PING_API_HTTP_URL_STRING}, {PING_API_HTTPS_URL_STRING}}; + } + + @Test(dataProvider = "protocollDataProvider") + public void shouldInitializeFromUrl(String urlString) { + // Given + URL urlMock = mockUrlConnection(urlString); + + // When + OpenApiSpecification specification = OpenApiSpecification.from(urlMock); + + // Then + assertEquals(specification.getSpecUrl(), urlString); + assertPingApi(specification); + } + + private void assertPingApi(OpenApiSpecification specification) { + assertNotNull(specification); + assertNotNull(specification.getSwaggerOpenApiValidationContext()); + Optional pingOperationPathAdapter = specification.getOperation( + PING_OPERATION_ID, + testContextMock); + assertTrue(pingOperationPathAdapter.isPresent()); + assertEquals(pingOperationPathAdapter.get().apiPath(), "/ping/{id}"); + assertNull(pingOperationPathAdapter.get().contextPath()); + assertEquals(pingOperationPathAdapter.get().fullPath(), "/ping/{id}"); + + Optional pongOperationPathAdapter = specification.getOperation( + PONG_OPERATION_ID, + testContextMock); + assertTrue(pongOperationPathAdapter.isPresent()); + assertEquals(pongOperationPathAdapter.get().apiPath(), "/pong/{id}"); + assertNull(pongOperationPathAdapter.get().contextPath()); + assertEquals(pongOperationPathAdapter.get().fullPath(), "/pong/{id}"); + } + + @Test + public void shouldInitializeFromResource() { + // Given + Resource resource= new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml"); + + // When + OpenApiSpecification specification = OpenApiSpecification.from(resource); + + // Then + assertNotNull(specification); + assertEquals(specification.getSpecUrl(), resource.getLocation()); + assertPingApi(specification); + } + + @Test + public void shouldReturnOpenApiDocWhenInitialized() { + //Given + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + OasDocument openApiDoc = specification.getOpenApiDoc(testContextMock); + + //When + OpenApiSpecification otherSpecification = new OpenApiSpecification(); + otherSpecification.setOpenApiDoc(openApiDoc); + OasDocument doc = otherSpecification.getOpenApiDoc(testContextMock); + + // Then + assertNotNull(doc); + assertEquals(doc, openApiDoc); + } + + @Test + public void shouldReturnEmptyOptionalWhenOperationIdIsNull() { + // When + Optional result = openApiSpecification.getOperation(null, + testContextMock); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + public void shouldReturnOperationWhenExists() { + // Given/When + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // Then + assertPingApi(specification); + } + + @Test + public void shouldInitializeDocumentWhenRequestingOperation() { + // Given/When + when(testContextMock.replaceDynamicContentInString(isA(String.class))).thenAnswer(answer-> + answer.getArgument(0) + ); + OpenApiSpecification specification = OpenApiSpecification.from("classpath:org/citrusframework/openapi/ping/ping-api.yaml"); + + // Then + Optional pingOperationPathAdapter = specification.getOperation( + PING_OPERATION_ID, + testContextMock); + assertTrue(pingOperationPathAdapter.isPresent()); + assertEquals(pingOperationPathAdapter.get().apiPath(), "/ping/{id}"); + assertNull(pingOperationPathAdapter.get().contextPath()); + assertEquals(pingOperationPathAdapter.get().fullPath(), "/ping/{id}"); + } + + @DataProvider(name = "lazyInitializationDataprovider") + public static Object[][] specSources() { + return new Object[][]{ + {null, "classpath:org/citrusframework/openapi/ping/ping-api.yaml"}, + {null, PING_API_HTTP_URL_STRING}, + {null, PING_API_HTTPS_URL_STRING}, + {null, "/ping-api.yaml"}, + {"http://org.citrus.sample", "/ping-api.yaml"} + }; + } + + @Test(dataProvider = "lazyInitializationDataprovider") + public void shouldDisableEnableRequestValidationWhenSet(String requestUrl, String specSource) { + + // Given + OpenApiSpecification specification = new OpenApiSpecification() { + + @Override + URL toSpecUrl(String resolvedSpecUrl) { + return mockUrlConnection(resolvedSpecUrl); + } + }; + + specification.setRequestUrl(requestUrl); + specification.setHttpClient("sampleHttpClient"); + specification.setSpecUrl(specSource); + when(testContextMock.replaceDynamicContentInString(isA(String.class))).thenAnswer(returnsFirstArg()); + + when(testContextMock.getReferenceResolver()).thenReturn(referenceResolverMock); + when(referenceResolverMock.isResolvable("sampleHttpClient", HttpClient.class)).thenReturn(true); + when(referenceResolverMock.resolve("sampleHttpClient", HttpClient.class)).thenReturn(httpClient); + when(httpClient.getEndpointConfiguration()).thenReturn(endpointConfigurationMock); + when(endpointConfigurationMock.getRequestUrl()).thenReturn("http://org.citrus.sample"); + + // When + specification.setApiRequestValidationEnabled(false); + + // Then (not yet initialized) + assertFalse(specification.isApiRequestValidationEnabled()); + assertNull(specification.getSwaggerOpenApiValidationContext()); + + // When (initialize) + specification.getOpenApiDoc(testContextMock); + + // Then + assertFalse(specification.isApiRequestValidationEnabled()); + assertNotNull(specification.getSwaggerOpenApiValidationContext()); + + // When + specification.setApiRequestValidationEnabled(true); + + // Then + assertTrue(specification.isApiRequestValidationEnabled()); + assertTrue(specification.getSwaggerOpenApiValidationContext().isRequestValidationEnabled()); + + } + + private static URL mockUrlConnection(String urlString) { + try { + HttpsURLConnection httpsURLConnectionMock = mock(); + when(httpsURLConnectionMock.getResponseCode()).thenReturn(200); + when(httpsURLConnectionMock.getInputStream()).thenAnswer( + invocation -> new ByteArrayInputStream(PING_API_STRING.getBytes( + StandardCharsets.UTF_8))); + + URL urlMock = mock(); + when(urlMock.getProtocol()).thenReturn(urlString.substring(0,urlString.indexOf(":"))); + when(urlMock.toString()).thenReturn(urlString); + when(urlMock.openConnection()).thenReturn(httpsURLConnectionMock); + return urlMock; + } catch (Exception e) { + throw new CitrusRuntimeException("Unable to mock spec url!", e); + } + } + + @Test + public void shouldDisableEnableResponseValidationWhenSet() { + // Given + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // When + specification.setApiResponseValidationEnabled(false); + + // Then + assertFalse(specification.isApiResponseValidationEnabled()); + assertNotNull(specification.getSwaggerOpenApiValidationContext()); + assertFalse(specification.getSwaggerOpenApiValidationContext().isResponseValidationEnabled()); + + // When + specification.setApiResponseValidationEnabled(true); + + // Then + assertTrue(specification.isApiResponseValidationEnabled()); + assertTrue(specification.getSwaggerOpenApiValidationContext().isResponseValidationEnabled()); + + } + + @Test + public void shouldAddAlias() { + String alias = "alias1"; + openApiSpecification.addAlias(alias); + + assertTrue(openApiSpecification.getAliases().contains(alias)); + } + + @Test + public void shouldReturnSpecUrl() { + URL url = openApiSpecification.toSpecUrl(PING_API_HTTP_URL_STRING); + + assertNotNull(url); + + assertEquals(url.toString(), PING_API_HTTP_URL_STRING); + } + + @Test + public void shouldSetRootContextPathAndReinitialize() { + // Given/When + OpenApiSpecification specification = OpenApiSpecification.from(new ClasspathResource("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + // Then + assertNull(openApiSpecification.getRootContextPath()); + + assertPingApi(specification); + + // When + specification.setRootContextPath("/root"); + + Optional pingOperationPathAdapter = specification.getOperation( + PING_OPERATION_ID, + testContextMock); + assertTrue(pingOperationPathAdapter.isPresent()); + assertEquals(pingOperationPathAdapter.get().apiPath(), "/ping/{id}"); + assertEquals(pingOperationPathAdapter.get().contextPath(), "/root"); + assertEquals(pingOperationPathAdapter.get().fullPath(), "/root/ping/{id}"); + + Optional pongOperationPathAdapter = specification.getOperation( + PONG_OPERATION_ID, + testContextMock); + assertTrue(pongOperationPathAdapter.isPresent()); + assertEquals(pongOperationPathAdapter.get().apiPath(), "/pong/{id}"); + assertEquals(pongOperationPathAdapter.get().contextPath(), "/root"); + assertEquals(pongOperationPathAdapter.get().fullPath(), "/root/pong/{id}"); + + // Verify initPathLookups is called, which would require a spy + } + + @Test + public void shouldSeAndProvideProperties() { + + openApiSpecification.setValidateOptionalFields(true); + openApiSpecification.setGenerateOptionalFields(true); + + assertTrue(openApiSpecification.isValidateOptionalFields()); + assertTrue(openApiSpecification.isGenerateOptionalFields()); + + openApiSpecification.setValidateOptionalFields(false); + openApiSpecification.setGenerateOptionalFields(false); + + assertFalse(openApiSpecification.isValidateOptionalFields()); + assertFalse(openApiSpecification.isGenerateOptionalFields()); + + } + + @Test + public void shouldReturnSpecUrlInAbsenceOfRequestUrl() { + + openApiSpecification.setSpecUrl(PING_API_HTTP_URL_STRING); + + assertEquals(openApiSpecification.getSpecUrl(), PING_API_HTTP_URL_STRING); + assertEquals(openApiSpecification.getRequestUrl(), PING_API_HTTP_URL_STRING); + + openApiSpecification.setSpecUrl("/ping-api.yaml"); + openApiSpecification.setRequestUrl("http://or.citrus.sample"); + + assertEquals(openApiSpecification.getSpecUrl(), "/ping-api.yaml"); + assertEquals(openApiSpecification.getRequestUrl(), "http://or.citrus.sample"); + + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java new file mode 100644 index 0000000000..6ac0dd9d60 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestDataGeneratorTest.java @@ -0,0 +1,395 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_DOUBLE; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_FLOAT; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_INT32; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_INT64; +import static org.citrusframework.openapi.OpenApiConstants.FORMAT_UUID; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_ARRAY; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_INTEGER; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_NUMBER; +import static org.citrusframework.openapi.OpenApiConstants.TYPE_STRING; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import com.atlassian.oai.validator.report.ValidationReport; +import com.atlassian.oai.validator.report.ValidationReport.Message; +import com.atlassian.oai.validator.schema.SchemaValidator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import io.swagger.v3.oas.models.media.Schema; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.citrusframework.context.TestContext; +import org.citrusframework.functions.DefaultFunctionRegistry; +import org.citrusframework.openapi.model.OasModelHelper; +import org.citrusframework.spi.Resources; +import org.testng.Assert; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class OpenApiTestDataGeneratorTest { + + private static final TestContext testContext = new TestContext(); + + private static OpenApiSpecification openApiSpecification; + + private static SchemaValidator schemaValidator; + + @BeforeClass + public static void beforeClass() { + testContext.setFunctionRegistry(new DefaultFunctionRegistry()); + + openApiSpecification = OpenApiSpecification.from( + Resources.fromClasspath("org/citrusframework/openapi/ping/ping-api.yaml")); + schemaValidator = openApiSpecification.getSwaggerOpenApiValidationContext() + .getSchemaValidator(); + } + + @Test + void testUuidFormat() { + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.format = FORMAT_UUID; + + String uuidRandomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema); + String finalUuidRandomValue = testContext.replaceDynamicContentInString(uuidRandomValue); + Pattern uuidPattern = Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + + assertTrue(uuidPattern.matcher(finalUuidRandomValue).matches()); + } + + @DataProvider(name = "testRandomNumber") + public static Object[][] testRandomNumber() { + return new Object[][]{ + {TYPE_INTEGER, FORMAT_INT32, null, 0, 2, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 2, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, -2, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, -2, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, null, 0, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 11, 0, 12, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 12, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 13, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 14, 0, 14, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 15, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 16, -16, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 17, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 18, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 19, -20, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT32, 20, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT32, 21, 21, 21, false, false}, + + {TYPE_INTEGER, FORMAT_INT64, null, 0, 2, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 2, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, -2, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, -2, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, null, 0, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 11, 0, 12, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 12, null, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 13, 0, 100, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 14, 0, 14, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 15, -100, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 16, -16, 0, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 17, 0, 100, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 18, -100, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 19, -20, 0, true, true}, + {TYPE_INTEGER, FORMAT_INT64, 20, 0, null, false, false}, + {TYPE_INTEGER, FORMAT_INT64, 21, 21, 21, false, false}, + + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 2, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, null, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 2, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -2, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, -2, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, null, 0, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 11.123f, 0, 13, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 12.123f, null, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 13.123f, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 14.123f, 0, 14, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 15.123f, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 16.123f, -16, 0, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 17.123f, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 18.123f, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 19.123f, -21, 0, true, true}, + {TYPE_NUMBER, FORMAT_FLOAT, 20.123f, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_FLOAT, 21.123f, 21.122f, 21.124f, false, false}, + + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 2, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, null, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 2, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -2, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, -2, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, null, 0, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 11.123d, 0, 13, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 12.123d, null, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 13.123d, 0, 100, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 14.123d, 0, 14, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 15.123d, -100, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 16.123d, -16, 0, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 17.123d, 0, 100, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 18.123d, -100, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 19.123d, -21, 0, true, true}, + {TYPE_NUMBER, FORMAT_DOUBLE, 20.123d, 0, null, false, false}, + {TYPE_NUMBER, FORMAT_DOUBLE, 21.123d, 21.122d, 21.124d, false, false}, + }; + } + + @Test(dataProvider = "testRandomNumber") + void testRandomNumber(String type, String format, Number multipleOf, Number minimum, + Number maximum, boolean exclusiveMinimum, boolean exclusiveMaximum) { + Oas30Schema testSchema = new Oas30Schema(); + testSchema.type = type; + testSchema.format = format; + testSchema.multipleOf = multipleOf; + testSchema.minimum = minimum; + testSchema.maximum = maximum; + testSchema.exclusiveMinimum = exclusiveMinimum; + testSchema.exclusiveMaximum = exclusiveMaximum; + + try { + for (int i = 0; i < 1000; i++) { + String randomValue = OpenApiTestDataGenerator.createOutboundPayload( + testSchema, openApiSpecification); + String finalRandomValue = testContext.resolveDynamicValue(randomValue); + BigDecimal value = new BigDecimal(finalRandomValue); + + if (multipleOf != null) { + BigDecimal remainder = value.remainder(new BigDecimal(multipleOf.toString())); + + assertEquals( + remainder.compareTo(BigDecimal.ZERO), 0, + "Expected %s to be a multiple of %s! Remainder is %s".formatted( + finalRandomValue, multipleOf, + remainder)); + } + + if (maximum != null) { + if (exclusiveMaximum) { + assertTrue(value.doubleValue() < testSchema.maximum.doubleValue(), + "Expected %s to be lower than %s!".formatted( + finalRandomValue, maximum)); + } else { + assertTrue(value.doubleValue() <= testSchema.maximum.doubleValue(), + "Expected %s to be lower or equal than %s!".formatted( + finalRandomValue, maximum)); + } + } + + if (minimum != null) { + if (exclusiveMinimum) { + assertTrue(value.doubleValue() > testSchema.minimum.doubleValue(), + "Expected %s to be larger than %s!".formatted( + finalRandomValue, minimum)); + } else { + assertTrue(value.doubleValue() >= testSchema.minimum.doubleValue(), + "Expected %s to be larger or equal than %s!".formatted( + finalRandomValue, minimum)); + } + } + } + } catch (Exception e) { + Assert.fail("Creation of multiple float threw an exception: " + e.getMessage(), e); + } + } + + @Test + void testPattern() { + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + + String exp = "[0-3]([a-c]|[e-g]{1,2})"; + stringSchema.pattern = exp; + + String randomValue = OpenApiTestDataGenerator.createRandomValueExpression(stringSchema); + String finalRandomValue = testContext.replaceDynamicContentInString(randomValue); + assertTrue(finalRandomValue.matches(exp), + "Value '%s' does not match expression '%s'".formatted(finalRandomValue, exp)); + } + + @DataProvider(name = "testPingApiSchemas") + public static Object[][] testPingApiSchemas() { + return new Object[][]{ + + // Composites currently do not work properly - validation fails + //{"AnyOfType"}, + //{"AllOfType"}, + //{"PingRespType"}, + {"OneOfType"}, + {"StringsType"}, + {"DatesType"}, + {"NumbersType"}, + {"PingReqType"}, + {"Detail1"}, + {"Detail2"}, + {"BooleanType"}, + {"EnumType"}, + {"NestedType"}, + {"MultipleOfType"}, + {"SimpleArrayType"}, + {"ComplexArrayType"}, + {"ArrayOfArraysType"}, + {"NullableType"}, + {"DefaultValueType"}, + }; + } + + + @Test(dataProvider = "testPingApiSchemas") + void testPingApiSchemas(String schemaType) throws IOException { + + OasSchema schema = OasModelHelper.getSchemaDefinitions( + openApiSpecification.getOpenApiDoc(null)).get(schemaType); + + Schema swaggerValidationSchema = openApiSpecification.getSwaggerOpenApiValidationContext() + .getSwaggerOpenApi().getComponents().getSchemas().get(schemaType); + + assertNotNull(schema); + + for (int i=0;i<100;i++) { + + String randomValue = OpenApiTestDataGenerator.createOutboundPayload(schema, + openApiSpecification); + assertNotNull(randomValue); + + String finalJsonAsText = testContext.replaceDynamicContentInString(randomValue); + try { + JsonNode valueNode = new ObjectMapper().readTree( + testContext.replaceDynamicContentInString(finalJsonAsText)); + ValidationReport validationReport = schemaValidator.validate(() -> valueNode, + swaggerValidationSchema, null); + + String message = """ + Json is invalid according to schema. + Message: %s + Report: %s + """.formatted(finalJsonAsText, validationReport.getMessages().stream().map( + Message::getMessage).collect(Collectors.joining("\n"))); + assertFalse(validationReport.hasErrors(), message); + } catch (JsonParseException e) { + Assert.fail("Unable to read generated schema to json: "+finalJsonAsText); + } + } + } + + @Test + void testArray() { + Oas30Schema arraySchema = new Oas30Schema(); + arraySchema.type = TYPE_ARRAY; + + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.minLength = 5; + stringSchema.maxLength = 15; + + arraySchema.items = stringSchema; + + for (int i = 0; i < 10; i++) { + String randomValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, + openApiSpecification); + int nElements = StringUtils.countMatches(randomValue, "citrus:randomString"); + assertTrue(nElements > 0); + } + } + + @Test + void testArrayMinItems() { + Oas30Schema arraySchema = new Oas30Schema(); + arraySchema.type = TYPE_ARRAY; + arraySchema.minItems = 5; + + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.minLength = 5; + stringSchema.maxLength = 15; + + arraySchema.items = stringSchema; + + for (int i = 0; i < 10; i++) { + String randomValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, + openApiSpecification); + int nElements = StringUtils.countMatches(randomValue, "citrus:randomString(15)"); + assertTrue(nElements <= 5); + } + } + + @Test + void testArrayMaxItems() { + Oas30Schema arraySchema = new Oas30Schema(); + arraySchema.type = TYPE_ARRAY; + arraySchema.minItems = 2; + arraySchema.maxItems = 5; + + Oas30Schema stringSchema = new Oas30Schema(); + stringSchema.type = TYPE_STRING; + stringSchema.minLength = 10; + stringSchema.maxLength = 15; + + arraySchema.items = stringSchema; + + Pattern pattern = Pattern.compile("citrus:randomString\\(1[0-5],MIXED,true,10\\)"); + for (int i = 0; i < 100; i++) { + String randomArrayValue = OpenApiTestDataGenerator.createOutboundPayload(arraySchema, + openApiSpecification); + + Matcher matcher = pattern.matcher(randomArrayValue); + int matches = 0; + while (matcher.find()) { + matches++; + } + + assertTrue(2 <= matches && matches <= 5, + "Expected random array string with number of elements between 2 and 4 but found %s: %s".formatted( + matches, randomArrayValue)); + } + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java new file mode 100644 index 0000000000..820a8cbbae --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiTestValidationDataGeneratorTest.java @@ -0,0 +1,71 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import static org.citrusframework.openapi.OpenApiTestValidationDataGenerator.createValidationExpression; +import static org.mockito.Mockito.mock; +import static org.testng.Assert.assertEquals; + +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema.Oas20AllOfSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.HashMap; +import java.util.List; +import org.testng.annotations.Test; + +public class OpenApiTestValidationDataGeneratorTest { + + @Test + public void anyOfIsIgnoredForOas3() { + + Oas30Schema anyOfSchema = new Oas30Schema(); + anyOfSchema.anyOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(createValidationExpression( + anyOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void allOfIsIgnoredForOas3() { + + Oas30Schema allOfSchema = new Oas30Schema(); + allOfSchema.allOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(createValidationExpression( + allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void oneOfIsIgnoredForOas3() { + + Oas30Schema oneOfSchema = new Oas30Schema(); + oneOfSchema.oneOf = List.of(new Oas30Schema(), new Oas30Schema()); + + assertEquals(createValidationExpression( + oneOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } + + @Test + public void allOfIsIgnoredForOas2() { + + Oas20AllOfSchema allOfSchema = new Oas20AllOfSchema(); + allOfSchema.allOf = List.of(new Oas20Schema(), new Oas20Schema()); + + assertEquals(createValidationExpression( + allOfSchema, new HashMap<>(), true, mock()), "\"@ignore@\""); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java new file mode 100644 index 0000000000..9dbc709fa6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/OpenApiUtilsTest.java @@ -0,0 +1,97 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.util.OpenApiUtils; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +public class OpenApiUtilsTest { + + @Mock + private HttpMessage httpMessageMock; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldReturnFormattedMethodPathWhenHttpMessageHasMethodAndPath() { + // Given + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_METHOD)).thenReturn("GET"); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/path"); + + // When + String methodPath = OpenApiUtils.getMethodPath(httpMessageMock); + + // Then + assertEquals(methodPath, "/get/api/path"); + } + + @Test + public void shouldReturnDefaultMethodPathWhenHttpMessageHasNoMethodAndPath() { + // Given + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_METHOD)).thenReturn(null); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn(null); + + // When + String methodPath = OpenApiUtils.getMethodPath(httpMessageMock); + + // Then + assertEquals(methodPath, "/null/null"); + } + + @Test + public void shouldReturnFormattedMethodPathWhenMethodAndPathAreProvided() { + // When + String methodPath = OpenApiUtils.getMethodPath("POST", "/api/path"); + // Then + assertEquals(methodPath, "/post/api/path"); + } + + @Test + public void shouldReturnFormattedMethodPathWhenMethodIsEmptyAndPathIsProvided() { + // When + String methodPath = OpenApiUtils.getMethodPath("", "/api/path"); + // Then + assertEquals(methodPath, "//api/path"); + } + + @Test + public void shouldReturnFormattedMethodPathWhenMethodAndPathAreEmpty() { + // When + String methodPath = OpenApiUtils.getMethodPath("", ""); + // Then + assertEquals(methodPath, "//"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java new file mode 100644 index 0000000000..c39605e35e --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/SampleOpenApiProcessor.java @@ -0,0 +1,30 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi; + +import java.util.ArrayList; +import java.util.List; + +public class SampleOpenApiProcessor implements OpenApiSpecificationProcessor { + + public static List processedSpecifications = new ArrayList<>(); + + @Override + public void process(OpenApiSpecification openApiSpecification) { + processedSpecifications.add(openApiSpecification); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java index 0b37361d0c..c3406e1555 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/actions/OpenApiActionBuilderTest.java @@ -16,12 +16,12 @@ package org.citrusframework.openapi.actions; -import java.util.Map; - import org.citrusframework.TestActionBuilder; import org.testng.Assert; import org.testng.annotations.Test; +import java.util.Map; + /** * @author Christoph Deppisch */ diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java index 83e9f20076..e0dd063102 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiClientTest.java @@ -16,11 +16,6 @@ package org.citrusframework.openapi.groovy; -import java.io.IOException; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ArrayBlockingQueue; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -57,6 +52,11 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.io.IOException; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; + import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; /** diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java index c5326f1259..c90aedaee4 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/groovy/OpenApiServerTest.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.groovy; -import java.util.Map; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -45,8 +43,13 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.util.Map; + import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; /** * @author Christoph Deppisch @@ -114,89 +117,90 @@ public void shouldLoadOpenApiServerActions() { testLoader.load(); TestCase result = testLoader.getTestCase(); - Assert.assertEquals(result.getName(), "OpenApiServerTest"); - Assert.assertEquals(result.getMetaInfo().getAuthor(), "Christoph"); - Assert.assertEquals(result.getMetaInfo().getStatus(), TestCaseMetaInfo.Status.FINAL); - Assert.assertEquals(result.getActionCount(), 4L); - Assert.assertEquals(result.getTestAction(0).getClass(), ReceiveMessageAction.class); - Assert.assertEquals(result.getTestAction(0).getName(), "openapi:receive-request"); + assertEquals(result.getName(), "OpenApiServerTest"); + assertEquals(result.getMetaInfo().getAuthor(), "Christoph"); + assertEquals(result.getMetaInfo().getStatus(), TestCaseMetaInfo.Status.FINAL); + assertEquals(result.getActionCount(), 4L); + assertEquals(result.getTestAction(0).getClass(), ReceiveMessageAction.class); + assertEquals(result.getTestAction(0).getName(), "openapi:receive-request"); - Assert.assertEquals(result.getTestAction(1).getClass(), SendMessageAction.class); - Assert.assertEquals(result.getTestAction(1).getName(), "openapi:send-response"); + assertEquals(result.getTestAction(1).getClass(), SendMessageAction.class); + assertEquals(result.getTestAction(1).getName(), "openapi:send-response"); int actionIndex = 0; ReceiveMessageAction receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); - Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); - Assert.assertEquals(receiveMessageAction.getReceiveTimeout(), 0L); + assertEquals(receiveMessageAction.getValidationContexts().size(), 3); + assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); + assertEquals(receiveMessageAction.getReceiveTimeout(), 0L); - Assert.assertTrue(receiveMessageAction.getMessageBuilder() instanceof HttpMessageBuilder); + assertTrue(receiveMessageAction.getMessageBuilder() instanceof HttpMessageBuilder); HttpMessageBuilder httpMessageBuilder = ((HttpMessageBuilder)receiveMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - Assert.assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), ""); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.GET.name()); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet/${petId}"); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet/${petId}"); + assertNotNull(httpMessageBuilder); + assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), ""); + assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.GET.name()); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet/${petId}"); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet/${petId}"); Assert.assertNull(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_QUERY_PARAMS)); Assert.assertNull(httpMessageBuilder.getMessage().getHeaders().get(EndpointUriResolver.ENDPOINT_URI_HEADER_NAME)); - Assert.assertEquals(receiveMessageAction.getEndpoint(), httpServer); - Assert.assertEquals(receiveMessageAction.getControlMessageProcessors().size(), 0); + assertEquals(receiveMessageAction.getEndpoint(), httpServer); + assertEquals(receiveMessageAction.getControlMessageProcessors().size(), 0); SendMessageAction sendMessageAction = (SendMessageAction) result.getTestAction(actionIndex++); httpMessageBuilder = ((HttpMessageBuilder)sendMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - - Assert.assertTrue(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()).toString().startsWith("{\"id\": ")); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); - Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); + assertNotNull(httpMessageBuilder); + + assertTrue(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()).toString().startsWith("{\"id\": ")); + assertEquals(httpMessageBuilder.getMessage().getHeaders().size(), 5L); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); + assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "application/json"); + Assert.assertNull(sendMessageAction.getEndpoint()); - Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); - Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 0); + assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); + assertEquals(sendMessageAction.getMessageProcessors().size(), 1); receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); - Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); - Assert.assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); - Assert.assertEquals(receiveMessageAction.getReceiveTimeout(), 2000L); + assertEquals(receiveMessageAction.getValidationContexts().size(), 3); + assertTrue(receiveMessageAction.getValidationContexts().get(0) instanceof HeaderValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(1) instanceof XmlMessageValidationContext); + assertTrue(receiveMessageAction.getValidationContexts().get(2) instanceof JsonMessageValidationContext); + assertEquals(receiveMessageAction.getReceiveTimeout(), 2000L); httpMessageBuilder = ((HttpMessageBuilder)receiveMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - Assert.assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), + assertNotNull(httpMessageBuilder); + assertEquals(httpMessageBuilder.buildMessagePayload(context, receiveMessageAction.getMessageType()), "{\"id\": \"@isNumber()@\",\"category\": {\"id\": \"@isNumber()@\",\"name\": \"@notEmpty()@\"},\"name\": \"@notEmpty()@\",\"photoUrls\": \"@ignore@\",\"tags\": \"@ignore@\",\"status\": \"@matches(available|pending|sold)@\"}"); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); Map requestHeaders = httpMessageBuilder.buildMessageHeaders(context); - Assert.assertEquals(requestHeaders.size(), 4L); - Assert.assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.POST.name()); - Assert.assertEquals(requestHeaders.get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet"); - Assert.assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet"); - Assert.assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "@startsWith(application/json)@"); + assertEquals(requestHeaders.size(), 4L); + assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_METHOD), HttpMethod.POST.name()); + assertEquals(requestHeaders.get(EndpointUriResolver.REQUEST_PATH_HEADER_NAME), "/petstore/v3/pet"); + assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_REQUEST_URI), "/petstore/v3/pet"); + assertEquals(requestHeaders.get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "@startsWith(application/json)@"); Assert.assertNull(receiveMessageAction.getEndpointUri()); - Assert.assertEquals(receiveMessageAction.getEndpoint(), httpServer); + assertEquals(receiveMessageAction.getEndpoint(), httpServer); sendMessageAction = (SendMessageAction) result.getTestAction(actionIndex); httpMessageBuilder = ((HttpMessageBuilder)sendMessageAction.getMessageBuilder()); - Assert.assertNotNull(httpMessageBuilder); - Assert.assertEquals(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()), ""); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); - Assert.assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); + assertNotNull(httpMessageBuilder); + assertEquals(httpMessageBuilder.buildMessagePayload(context, sendMessageAction.getMessageType()), ""); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.ID)); + assertNotNull(httpMessageBuilder.getMessage().getHeaders().get(MessageHeaders.TIMESTAMP)); Map responseHeaders = httpMessageBuilder.buildMessageHeaders(context); - Assert.assertEquals(responseHeaders.size(), 2L); - Assert.assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_STATUS_CODE), 201); - Assert.assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_REASON_PHRASE), "CREATED"); + assertEquals(responseHeaders.size(), 2L); + assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_STATUS_CODE), 201); + assertEquals(responseHeaders.get(HttpMessageHeaders.HTTP_REASON_PHRASE), "CREATED"); Assert.assertNull(sendMessageAction.getEndpoint()); - Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); + assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java index ffa4e9dcdf..e33daf0770 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiClientIT.java @@ -16,28 +16,38 @@ package org.citrusframework.openapi.integration; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + import org.citrusframework.annotations.CitrusTest; +import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.http.actions.HttpClientRequestActionBuilder.HttpMessageBuilderSupport; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpClientBuilder; import org.citrusframework.http.server.HttpServer; import org.citrusframework.http.server.HttpServerBuilder; import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiClientResponseActionBuilder; import org.citrusframework.spi.BindToRegistry; import org.citrusframework.spi.Resources; import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; import org.citrusframework.util.SocketUtils; import org.springframework.http.HttpStatus; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Ignore; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; - /** * @author Christoph Deppisch */ -@Test public class OpenApiClientIT extends TestNGCitrusSpringSupport { + public static final String VALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet.json"; + public static final String INVALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet_invalid.json"; + private final int port = SocketUtils.findAvailableTcpPort(8080); @BindToRegistry @@ -56,13 +66,36 @@ public class OpenApiClientIT extends TestNGCitrusSpringSupport { private final OpenApiSpecification petstoreSpec = OpenApiSpecification.from( Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); + private final OpenApiSpecification pingSpec = OpenApiSpecification.from( + Resources.create("classpath:org/citrusframework/openapi/ping/ping-api.yaml")); + + @CitrusTest + @Test + public void shouldExecuteGetPetByIdFromDirectSpec() { + shouldExecuteGetPetById(openapi(petstoreSpec), VALID_PET_PATH, true, false); + } + @CitrusTest - public void getPetById() { + @Test + public void shouldFailOnMissingNameInResponse() { + shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, false, false); + } + + @CitrusTest + @Test + public void shouldSucceedOnMissingNameInResponseWithValidationDisabled() { + shouldExecuteGetPetById(openapi(petstoreSpec), INVALID_PET_PATH, true, true); + } + + private void shouldExecuteGetPetById(OpenApiActionBuilder openapi, String responseFile, + boolean valid, boolean disableValidation) { + variable("petId", "1001"); - when(openapi(petstoreSpec) + when(openapi .client(httpClient) .send("getPetById") + .message() .fork(true)); then(http().server(httpServer) @@ -75,19 +108,83 @@ public void getPetById() { .send() .response(HttpStatus.OK) .message() - .body(Resources.create("classpath:org/citrusframework/openapi/petstore/pet.json")) + .body(Resources.create(responseFile)) .contentType("application/json")); - then(openapi(petstoreSpec) - .client(httpClient) - .receive("getPetById", HttpStatus.OK)); + OpenApiClientResponseActionBuilder clientResponseActionBuilder = openapi + .client(httpClient).receive("getPetById", HttpStatus.OK) + .disableOasValidation(disableValidation); + + if (valid) { + then(clientResponseActionBuilder); + } else { + assertThrows(() -> then(clientResponseActionBuilder)); + } + } + + @CitrusTest + @Test + public void shouldProperlyExecuteGetAndAddPetFromDirectSpec() { + shouldExecuteGetAndAddPet(openapi(petstoreSpec)); + } + + @CitrusTest + @Test + public void shouldProperlyExecuteGetAndAddPetFromRepository() { + shouldExecuteGetAndAddPet(openapi(petstoreSpec)); + } + + @CitrusTest + @Test + public void shouldFailOnMissingNameInRequest() { + variable("petId", "1001"); + + HttpMessageBuilderSupport addPetBuilder = openapi(petstoreSpec) + .client(httpClient) + .send("addPet") + .message().body(Resources.create(INVALID_PET_PATH)) + .fork(true); + + assertThrows(TestCaseFailedException.class, () ->when(addPetBuilder)); + } + + @CitrusTest + @Test + public void shouldFailOnWrongQueryIdType() { + variable("petId", "xxxx"); + HttpMessageBuilderSupport addPetBuilder = openapi(petstoreSpec) + .client(httpClient) + .send("addPet") + .message().body(Resources.create(VALID_PET_PATH)) + .fork(true); + + assertThrows(TestCaseFailedException.class, () ->when(addPetBuilder)); } @CitrusTest - public void getAddPet() { + @Test + public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { + variable("petId", "xxxx"); + HttpMessageBuilderSupport addPetBuilder = openapi(petstoreSpec) + .client(httpClient) + .send("addPet") + .disableOasValidation(true) + .message().body(Resources.create(VALID_PET_PATH)) + .fork(true); + + try { + when(addPetBuilder); + } catch (Exception e) { + fail("Method threw an exception: " + e.getMessage()); + } + + } + + private void shouldExecuteGetAndAddPet(OpenApiActionBuilder openapi) { + variable("petId", "1001"); - when(openapi(petstoreSpec) + when(openapi .client(httpClient) .send("addPet") .fork(true)); @@ -116,8 +213,41 @@ public void getAddPet() { .response(HttpStatus.CREATED) .message()); - then(openapi(petstoreSpec) + then(openapi .client(httpClient) .receive("addPet", HttpStatus.CREATED)); } + + @DataProvider(name="pingApiOperationDataprovider") + public static Object[][] pingApiOperationDataprovider() { + return new Object[][]{{"doPing"}, {"doPong"}, {"doPung"}}; + } + + @Test(dataProvider = "pingApiOperationDataprovider") + @CitrusTest + @Ignore // Solve issue with composite schemes + public void shouldPerformRoundtripPingOperation(String pingApiOperation) { + + variable("id", 2001); + when(openapi(pingSpec) + .client(httpClient) + .send(pingApiOperation) + .message() + .fork(true)); + + then(openapi(pingSpec).server(httpServer) + .receive(pingApiOperation) + .message() + .accept("@contains('application/json')@")); + + then(openapi(pingSpec).server(httpServer) + .send(pingApiOperation) + .message() + .contentType("application/json")); + + OpenApiClientResponseActionBuilder clientResponseActionBuilder = openapi(pingSpec) + .client(httpClient).receive(pingApiOperation, HttpStatus.OK); + + then(clientResponseActionBuilder); + } } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java index 216445a5d3..662276d059 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/integration/OpenApiServerIT.java @@ -16,12 +16,22 @@ package org.citrusframework.openapi.integration; +import static org.citrusframework.http.actions.HttpActionBuilder.http; +import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.fail; + import org.citrusframework.annotations.CitrusTest; +import org.citrusframework.exceptions.TestCaseFailedException; +import org.citrusframework.http.actions.HttpServerResponseActionBuilder.HttpMessageBuilderSupport; import org.citrusframework.http.client.HttpClient; import org.citrusframework.http.client.HttpClientBuilder; import org.citrusframework.http.server.HttpServer; import org.citrusframework.http.server.HttpServerBuilder; import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.actions.OpenApiActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerRequestActionBuilder; +import org.citrusframework.openapi.actions.OpenApiServerResponseActionBuilder; import org.citrusframework.spi.BindToRegistry; import org.citrusframework.spi.Resources; import org.citrusframework.testng.spring.TestNGCitrusSpringSupport; @@ -29,15 +39,15 @@ import org.springframework.http.HttpStatus; import org.testng.annotations.Test; -import static org.citrusframework.http.actions.HttpActionBuilder.http; -import static org.citrusframework.openapi.actions.OpenApiActionBuilder.openapi; - /** * @author Christoph Deppisch */ @Test public class OpenApiServerIT extends TestNGCitrusSpringSupport { + public static final String VALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet.json"; + public static final String INVALID_PET_PATH = "classpath:org/citrusframework/openapi/petstore/pet_invalid.json"; + private final int port = SocketUtils.findAvailableTcpPort(8080); @BindToRegistry @@ -53,11 +63,14 @@ public class OpenApiServerIT extends TestNGCitrusSpringSupport { .requestUrl("http://localhost:%d/petstore/v3".formatted(port)) .build(); + /** + * Directly loaded open api. + */ private final OpenApiSpecification petstoreSpec = OpenApiSpecification.from( - Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); + Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.json")); @CitrusTest - public void getPetById() { + public void shouldExecuteGetPetById() { variable("petId", "1001"); when(http() @@ -97,7 +110,176 @@ public void getPetById() { } @CitrusTest - public void getAddPet() { + public void executeGetPetByIdShouldFailOnInvalidResponse() { + variable("petId", "1001"); + + when(http() + .client(httpClient) + .send() + .get("/pet/${petId}") + .message() + .accept("application/json") + .fork(true)); + + then(openapi(petstoreSpec) + .server(httpServer) + .receive("getPetById")); + + HttpMessageBuilderSupport getPetByIdResponseBuilder = openapi(petstoreSpec) + .server(httpServer) + .send("getPetById", HttpStatus.OK) + .message().body(""" + { + "id": "xxxx", + "name": "Garfield", + "category": { + "id": 111, + "name": "Comic" + }, + "photoUrls": [], + "tags": [], + "status": "available" + } + """); + assertThrows(TestCaseFailedException.class, () ->then(getPetByIdResponseBuilder)); + } + + @CitrusTest + public void executeGetPetByIdShouldSucceedOnInvalidResponseWithValidationDisabled() { + variable("petId", "1001"); + + when(http() + .client(httpClient) + .send() + .get("/pet/${petId}") + .message() + .accept("application/json") + .fork(true)); + + then(openapi(petstoreSpec) + .server(httpServer) + .receive("getPetById")); + + HttpMessageBuilderSupport getPetByIdResponseBuilder = openapi(petstoreSpec) + .server(httpServer) + .send("getPetById", HttpStatus.OK) + .disableOasValidation(true) + .message().body(""" + { + "id": "xxxx", + "name": "Garfield", + "category": { + "id": 111, + "name": "Comic" + }, + "photoUrls": [], + "tags": [], + "status": "available" + } + """); + then(getPetByIdResponseBuilder); + + then(http() + .client(httpClient) + .receive() + .response(HttpStatus.OK) + .message() + .body(""" + { + "id": "xxxx", + "name": "Garfield", + "category": { + "id": 111, + "name": "Comic" + }, + "photoUrls": [], + "tags": [], + "status": "available" + } + """)); + } + + @CitrusTest + public void shouldExecuteAddPet() { + shouldExecuteAddPet(openapi(petstoreSpec), VALID_PET_PATH, true); + } + + @CitrusTest + public void shouldFailOnMissingNameInRequest() { + shouldExecuteAddPet(openapi(petstoreSpec), INVALID_PET_PATH, false); + } + + @CitrusTest + public void shouldFailOnMissingNameInResponse() { + variable("petId", "1001"); + + when(http() + .client(httpClient) + .send() + .get("/pet/${petId}") + .message() + .accept("application/json") + .fork(true)); + + then(openapi(petstoreSpec) + .server(httpServer) + .receive("getPetById")); + + OpenApiServerResponseActionBuilder sendMessageActionBuilder = openapi(petstoreSpec) + .server(httpServer) + .send("getPetById", HttpStatus.OK); + sendMessageActionBuilder.message().body(Resources.create(INVALID_PET_PATH)); + + assertThrows(TestCaseFailedException.class, () -> then(sendMessageActionBuilder)); + + } + + @CitrusTest + public void shouldFailOnWrongQueryIdTypeWithOasDisabled() { + variable("petId", "xxx"); + + when(http() + .client(httpClient) + .send() + .post("/pet") + .message() + .body(Resources.create(VALID_PET_PATH)) + .contentType("application/json") + .fork(true)); + + OpenApiServerRequestActionBuilder addPetBuilder = openapi(petstoreSpec) + .server(httpServer) + .receive("addPet"); + + assertThrows(TestCaseFailedException.class, () -> then(addPetBuilder)); + } + + @CitrusTest + public void shouldSucceedOnWrongQueryIdTypeWithOasDisabled() { + variable("petId", -1); + + when(http() + .client(httpClient) + .send() + .post("/pet") + .message() + .body(Resources.create(VALID_PET_PATH)) + .contentType("application/json") + .fork(true)); + + OpenApiServerRequestActionBuilder addPetBuilder = openapi(petstoreSpec) + .server(httpServer) + .receive("addPet") + .disableOasValidation(false); + + try { + when(addPetBuilder); + } catch (Exception e) { + fail("Method threw an exception: " + e.getMessage()); + } + } + + private void shouldExecuteAddPet(OpenApiActionBuilder openapi, String requestFile, boolean valid) { variable("petId", "1001"); when(http() @@ -105,15 +287,20 @@ public void getAddPet() { .send() .post("/pet") .message() - .body(Resources.create("classpath:org/citrusframework/openapi/petstore/pet.json")) + .body(Resources.create(requestFile)) .contentType("application/json") .fork(true)); - then(openapi(petstoreSpec) - .server(httpServer) - .receive("addPet")); + OpenApiServerRequestActionBuilder receiveActionBuilder = openapi + .server(httpServer) + .receive("addPet"); + if (valid) { + then(receiveActionBuilder); + } else { + assertThrows(() -> then(receiveActionBuilder)); + } - then(openapi(petstoreSpec) + then(openapi .server(httpServer) .send("addPet", HttpStatus.CREATED)); @@ -122,4 +309,5 @@ public void getAddPet() { .receive() .response(HttpStatus.CREATED)); } + } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java new file mode 100644 index 0000000000..d24101fb35 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/OperationPathAdapterTest.java @@ -0,0 +1,42 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.model; + +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import org.citrusframework.openapi.util.OpenApiUtils; +import org.testng.annotations.Test; + +import static java.lang.String.format; +import static org.testng.Assert.assertEquals; + +public class OperationPathAdapterTest { + + @Test + public void shouldReturnFormattedStringWhenToStringIsCalled() { + // Given + Oas30Operation oas30Operation = new Oas30Operation("get"); + oas30Operation.operationId = "operationId"; + + OperationPathAdapter adapter = new OperationPathAdapter("/api/path", "/context/path", "/full/path", oas30Operation); + + // When + String expectedString = format("%s (%s)", OpenApiUtils.getMethodPath("GET", "/api/path"), "operationId"); + + // Then + assertEquals(adapter.toString(), expectedString); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java new file mode 100644 index 0000000000..501c497f7c --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v2/Oas20ModelHelperTest.java @@ -0,0 +1,147 @@ +package org.citrusframework.openapi.model.v2; + +import io.apicurio.datamodels.openapi.models.OasResponse; +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v2.models.Oas20Document; +import io.apicurio.datamodels.openapi.v2.models.Oas20Items; +import io.apicurio.datamodels.openapi.v2.models.Oas20Operation; +import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; +import io.apicurio.datamodels.openapi.v2.models.Oas20Response; +import io.apicurio.datamodels.openapi.v2.models.Oas20Responses; +import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; +import org.citrusframework.openapi.model.OasModelHelper; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.Optional; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +public class Oas20ModelHelperTest { + + @Test + public void shouldFindRandomResponseWithGoodStatusCode() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse = new Oas20Response("403"); + nokResponse.schema = new Oas20Schema(); + + Oas20Response okResponse = new Oas20Response("200"); + okResponse.schema = new Oas20Schema(); + + operation.responses = new Oas20Responses(); + operation.responses.addResponse("403", nokResponse); + operation.responses.addResponse("200", okResponse); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(okResponse, responseForRandomGeneration.get()); + } + + @Test + public void shouldFindFirstResponseInAbsenceOfAGoodOne() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse403 = new Oas20Response("403"); + nokResponse403.schema = new Oas20Schema(); + Oas20Response nokResponse407 = new Oas20Response("407"); + nokResponse407.schema = new Oas20Schema(); + + operation.responses = new Oas20Responses(); + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "403"); + } + + @Test + public void shouldFindDefaultResponseInAbsenceOfAGoodOne() { + Oas20Document document = new Oas20Document(); + Oas20Operation operation = new Oas20Operation("GET"); + + operation.responses = new Oas20Responses(); + + Oas20Response nokResponse403 = new Oas20Response("403"); + nokResponse403.schema = new Oas20Schema(); + Oas20Response nokResponse407 = new Oas20Response("407"); + nokResponse407.schema = new Oas20Schema(); + + operation.responses = new Oas20Responses(); + operation.responses.default_ = nokResponse407; + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "407"); + } + + @Test + public void shouldFindParameterSchema() { + Oas20Parameter parameter = new Oas20Parameter(); + parameter.schema = new Oas20Schema(); + + Optional parameterSchema = Oas20ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isPresent()); + assertEquals(parameter.schema, parameterSchema.get()); + } + + @Test + public void shouldFindSchemaFromParameter() { + Oas20Parameter parameter = new Oas20Parameter("testParameter"); + parameter.type = "string"; + parameter.format = "date-time"; + parameter.items = new Oas20Items(); + parameter.multipleOf = 2; + parameter.default_ = "defaultValue"; + parameter.enum_ = List.of("value1", "value2"); + parameter.pattern = "pattern"; + parameter.description = "description"; + parameter.uniqueItems = true; + parameter.maximum = 100.0; + parameter.maxItems = 10; + parameter.maxLength = 20; + parameter.exclusiveMaximum = true; + parameter.minimum = 0.0; + parameter.minItems = 1; + parameter.minLength = 5; + parameter.exclusiveMinimum = false; + + Optional schemaOptional = Oas20ModelHelper.getParameterSchema(parameter); + assertTrue(schemaOptional.isPresent()); + + OasSchema parameterSchema = schemaOptional.get(); + assertEquals(parameterSchema.title, "testParameter"); + assertEquals(parameterSchema.type, "string"); + assertEquals(parameterSchema.format, "date-time"); + assertEquals(parameter.items, parameterSchema.items); + assertEquals(parameter.multipleOf, parameterSchema.multipleOf); + assertEquals(parameter.default_, parameterSchema.default_); + assertEquals(parameter.enum_, parameterSchema.enum_); + assertEquals(parameter.pattern, parameterSchema.pattern); + assertEquals(parameter.description, parameterSchema.description); + assertEquals(parameter.uniqueItems, parameterSchema.uniqueItems); + assertEquals(parameter.maximum, parameterSchema.maximum); + assertEquals(parameter.maxItems, parameterSchema.maxItems); + assertEquals(parameter.maxLength, parameterSchema.maxLength); + assertEquals(parameter.exclusiveMaximum, parameterSchema.exclusiveMaximum); + assertEquals(parameter.minimum, parameterSchema.minimum); + assertEquals(parameter.minItems, parameterSchema.minItems); + assertEquals(parameter.minLength, parameterSchema.minLength); + assertEquals(parameter.exclusiveMinimum, parameterSchema.exclusiveMinimum); + + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java index 38d8b13fde..b26f2f7780 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/model/v3/Oas30ModelHelperTest.java @@ -1,13 +1,26 @@ package org.citrusframework.openapi.model.v3; +import io.apicurio.datamodels.openapi.models.OasResponse; import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Document; import io.apicurio.datamodels.openapi.v3.models.Oas30Header; +import io.apicurio.datamodels.openapi.v3.models.Oas30MediaType; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; +import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; +import io.apicurio.datamodels.openapi.v3.models.Oas30Responses; import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; -import org.testng.Assert; +import org.citrusframework.openapi.model.OasModelHelper; +import org.springframework.http.MediaType; import org.testng.annotations.Test; +import java.util.Collection; import java.util.Map; +import java.util.Optional; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; public class Oas30ModelHelperTest { @@ -15,40 +28,162 @@ public class Oas30ModelHelperTest { public void shouldNotFindRequiredHeadersWithoutRequiredAttribute() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = null; // explicitely assigned because this is test case + header.required = null; var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 0); + assertEquals(result.size(), 0); } @Test public void shouldFindRequiredHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.TRUE; // explicitely assigned because this is test case + header.required = Boolean.TRUE; var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 1); - Assert.assertSame(result.get(header.getName()), header.schema); + assertEquals(result.size(), 1); + assertSame(result.get(header.getName()), header.schema); } @Test public void shouldNotFindOptionalHeaders() { var header = new Oas30Header("X-TEST"); header.schema = new Oas30Schema(); - header.required = Boolean.FALSE; // explicitely assigned because this is test case + header.required = Boolean.FALSE; var response = new Oas30Response("200"); response.headers.put(header.getName(), header); Map result = Oas30ModelHelper.getRequiredHeaders(response); - Assert.assertEquals(result.size(), 0); + assertEquals(result.size(), 0); + } + + @Test + public void shouldFindAllRequestTypesForOperation() { + Oas30Operation operation = new Oas30Operation("GET"); + operation.responses = new Oas30Responses(); + + Oas30Response response = new Oas30Response("200"); + response.content = Map.of(MediaType.APPLICATION_JSON_VALUE, + new Oas30MediaType(MediaType.APPLICATION_JSON_VALUE), + MediaType.APPLICATION_XML_VALUE, new Oas30MediaType(MediaType.APPLICATION_XML_VALUE)); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("200", response); + + Collection responseTypes = Oas30ModelHelper.getResponseTypes(operation, response); + + assertTrue(responseTypes.contains(MediaType.APPLICATION_JSON_VALUE)); + assertTrue(responseTypes.contains(MediaType.APPLICATION_XML_VALUE)); + + } + + @Test + public void shouldFindRandomResponseWithGoodStatusCode() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response okResponse = new Oas30Response("200"); + Oas30MediaType jsonMediaType = new Oas30MediaType(MediaType.APPLICATION_JSON_VALUE); + jsonMediaType.schema = new Oas30Schema(); + + Oas30MediaType xmlMediaType = new Oas30MediaType(MediaType.APPLICATION_XML_VALUE); + xmlMediaType.schema = new Oas30Schema(); + + okResponse.content = Map.of(MediaType.APPLICATION_JSON_VALUE, jsonMediaType, + MediaType.APPLICATION_XML_VALUE, xmlMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("403", nokResponse); + operation.responses.addResponse("200", okResponse); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(okResponse, responseForRandomGeneration.get()); + } + + @Test + public void shouldFindFirstResponseInAbsenceOfAGoodOne() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse403 = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse403.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response nokResponse407 = new Oas30Response("407"); + nokResponse407.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "403"); + + } + + @Test + public void shouldFindDefaultResponseInAbsenceOfAGoodOne() { + Oas30Document document = new Oas30Document(); + Oas30Operation operation = new Oas30Operation("GET"); + + operation.responses = new Oas30Responses(); + + Oas30Response nokResponse403 = new Oas30Response("403"); + Oas30MediaType plainTextMediaType = new Oas30MediaType(MediaType.TEXT_PLAIN_VALUE); + plainTextMediaType.schema = new Oas30Schema(); + nokResponse403.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + Oas30Response nokResponse407 = new Oas30Response("407"); + nokResponse407.content = Map.of(MediaType.TEXT_PLAIN_VALUE, plainTextMediaType); + + operation.responses = new Oas30Responses(); + operation.responses.default_ = nokResponse407; + operation.responses.addResponse("403", nokResponse403); + operation.responses.addResponse("407", nokResponse407); + + Optional responseForRandomGeneration = OasModelHelper.getResponseForRandomGeneration( + document, operation, null, null); + assertTrue(responseForRandomGeneration.isPresent()); + assertEquals(responseForRandomGeneration.get().getStatusCode(), "407"); + } + + @Test + public void shouldFindParameterSchema() { + Oas30Parameter parameter = new Oas30Parameter(); + parameter.schema = new Oas30Schema(); + + Optional parameterSchema = Oas30ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isPresent()); + assertEquals(parameter.schema, parameterSchema.get()); + } + + @Test + public void shouldNotFindParameterSchema() { + Oas30Parameter parameter = new Oas30Parameter(); + + Optional parameterSchema = Oas30ModelHelper.getParameterSchema(parameter); + assertTrue(parameterSchema.isEmpty()); } } \ No newline at end of file diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java new file mode 100644 index 0000000000..43fd6d628b --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/OasRandomConfigurationTest.java @@ -0,0 +1,179 @@ +package org.citrusframework.openapi.random; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.util.List; + +import static org.citrusframework.openapi.OpenApiConstants.*; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +public class OasRandomConfigurationTest { + + private RandomConfiguration randomConfiguration; + + @BeforeClass + public void setUp() { + randomConfiguration = RandomConfiguration.RANDOM_CONFIGURATION; + } + + @Test + public void testGetGeneratorForDateFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = FORMAT_DATE; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForDateTimeFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = FORMAT_DATE_TIME; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForUUIDFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = FORMAT_UUID; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForEmailFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "email"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForURIFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "uri"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForHostnameFormat() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "hostname"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForIPv4Format() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "ipv4"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForIPv6Format() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + schema.format = "ipv6"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForBooleanType() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_BOOLEAN; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForStringType() { + OasSchema schema = new Oas30Schema(); + schema.type = TYPE_STRING; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForNumberType() { + OasSchema schema = new Oas30Schema(); + schema.type = "number"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForObjectType() { + OasSchema schema = new Oas30Schema(); + schema.type = "object"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForArrayType() { + OasSchema schema = new Oas30Schema(); + schema.type = "array"; + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForEnum() { + OasSchema schema = new Oas30Schema(); + schema.enum_ = List.of("value1", "value2"); + + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertTrue(generator.handles(schema)); + } + + @Test + public void testGetGeneratorForNullSchema() { + OasSchema schema = new Oas30Schema(); + RandomGenerator generator = randomConfiguration.getGenerator(schema); + assertNotNull(generator); + assertSame(generator, RandomGenerator.NULL_GENERATOR); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java new file mode 100644 index 0000000000..c361e0f1f6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomArrayGeneratorTest.java @@ -0,0 +1,106 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.citrusframework.openapi.OpenApiConstants; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomArrayGeneratorTest { + + private RandomArrayGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder builderSpy; + + @BeforeMethod + public void setUp() { + generator = new RandomArrayGenerator(); + mockContext = mock(); + + builderSpy = spy(new RandomModelBuilder(true)); + + when(mockContext.getRandomModelBuilder()).thenReturn(builderSpy); + } + + @Test + public void testGenerateArrayWithDefaultItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atLeastOnce()).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateArrayWithMinItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + schema.minItems = 5; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atLeast(5)).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateArrayWithMaxItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + schema.maxItems = 3; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atMost(3)).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateArrayWithMinMaxItems() { + Oas30Schema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_ARRAY; + schema.minItems = 2; + schema.maxItems = 5; + + Oas30Schema itemsSchema = new Oas30Schema(); + itemsSchema.type = OpenApiConstants.TYPE_STRING; + schema.items = itemsSchema; + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).array(any()); + verify(mockContext, atLeast(2)).generate(any(OasSchema.class)); + verify(mockContext, atMost(5)).generate(any(OasSchema.class)); + } + + @Test(expectedExceptions = UnsupportedOperationException.class) + public void testGenerateArrayWithUnsupportedItems() { + Oas30Schema schema = new Oas30Schema(); + schema.items = new Object(); // Unsupported items type + + generator.generate(mockContext, schema); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java new file mode 100644 index 0000000000..ab2da578e7 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomCompositeGeneratorTest.java @@ -0,0 +1,90 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.assertArg; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.Collections; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomCompositeGeneratorTest { + + private RandomCompositeGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder builderSpy; + + @BeforeMethod + public void setUp() { + generator = new RandomCompositeGenerator(); + mockContext = mock(RandomContext.class); + builderSpy = spy(new RandomModelBuilder(true)); + + when(mockContext.getRandomModelBuilder()).thenReturn(builderSpy); + } + + @Test + public void testHandlesCompositeSchema() { + Oas30Schema schema = new Oas30Schema(); + schema.allOf = Collections.singletonList(new Oas30Schema()); + + assertTrue(generator.handles(schema)); + } + + @Test + public void testGenerateAllOf() { + Oas30Schema schema = new Oas30Schema(); + schema.allOf = List.of(new Oas30Schema(), new Oas30Schema(), new Oas30Schema()); + + generator.generate(mockContext, schema); + + verify(builderSpy).object(any()); + verify(mockContext).generate(schema.allOf.get(0)); + verify(mockContext).generate(schema.allOf.get(1)); + verify(mockContext).generate(schema.allOf.get(2)); + } + + @Test + public void testGenerateAnyOf() { + Oas30Schema schema = new Oas30Schema(); + schema.anyOf = List.of(new Oas30Schema(), new Oas30Schema(), new Oas30Schema()); + + generator.generate(mockContext, schema); + + verify(builderSpy).object(any()); + verify(mockContext, atLeast(1)).generate(assertArg(arg -> schema.anyOf.contains(arg))); + verify(mockContext, atMost(3)).generate(assertArg(arg -> schema.anyOf.contains(arg))); + } + + @Test + public void testGenerateOneOf() { + Oas30Schema schema = new Oas30Schema(); + schema.oneOf = List.of(new Oas30Schema(), new Oas30Schema(), new Oas30Schema()); + + generator.generate(mockContext, schema); + + verify(builderSpy, atLeastOnce()).object(any()); + verify(mockContext).generate(any(OasSchema.class)); + } + + @Test + public void testGenerateWithNoCompositeSchema() { + Oas30Schema schema = new Oas30Schema(); + + generator.generate(mockContext, schema); + + verify(builderSpy, never()).object(any()); + verify(mockContext, never()).generate(any(OasSchema.class)); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java new file mode 100644 index 0000000000..78d47bb52e --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomContextTest.java @@ -0,0 +1,76 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertSame; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.HashMap; +import java.util.Map; +import org.citrusframework.openapi.OpenApiSpecification; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomContextTest { + + private OpenApiSpecification specificationMock; + + private RandomContext randomContext; + + private Map schemaDefinitions; + + @BeforeMethod + public void setUp() { + RandomModelBuilder randomModelBuilderMock = mock(); + specificationMock = mock(); + + schemaDefinitions =new HashMap<>(); + + randomContext = spy(new RandomContext(specificationMock, true)); + ReflectionTestUtils.setField(randomContext, "randomModelBuilder", randomModelBuilderMock); + + doReturn(schemaDefinitions).when(randomContext).getSchemaDefinitions(); + } + + @Test + public void testGenerateWithResolvedSchema() { + OasSchema oasSchema = new Oas30Schema(); + randomContext.generate(oasSchema); + verify(randomContext).doGenerate(oasSchema); + } + + @Test + public void testGenerateWithReferencedSchema() { + OasSchema referencedSchema = new Oas30Schema(); + schemaDefinitions.put("reference", referencedSchema); + OasSchema oasSchema = new Oas30Schema(); + oasSchema.$ref = "reference"; + + randomContext.generate(oasSchema); + verify(randomContext).doGenerate(referencedSchema); + } + + @Test + public void testGetRandomModelBuilder() { + assertNotNull(randomContext.getRandomModelBuilder()); + } + + @Test + public void testGetSpecification() { + assertEquals(randomContext.getSpecification(), specificationMock); + } + + @Test + public void testCacheVariable() { + HashMap cachedValue1 = randomContext.get("testKey", k -> new HashMap<>()); + HashMap cachedValue2 = randomContext.get("testKey", k -> new HashMap<>()); + + assertSame(cachedValue1, cachedValue2); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomElementTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomElementTest.java new file mode 100644 index 0000000000..0f17bd143e --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomElementTest.java @@ -0,0 +1,89 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.random; + +import static org.testng.Assert.assertEquals; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomElementTest { + + private RandomElement.RandomList randomList; + private RandomElement.RandomObject randomObject; + private RandomElement.RandomValue randomValue; + + @BeforeMethod + public void setUp() { + randomList = new RandomElement.RandomList(); + randomObject = new RandomElement.RandomObject(); + randomValue = new RandomElement.RandomValue(); + } + + @Test + public void testRandomListPushValue() { + randomList.push("testValue"); + assertEquals(randomList.size(), 1); + assertEquals(randomList.get(0), "testValue"); + } + + @Test + public void testRandomListPushKeyValue() { + randomList.push(new RandomElement.RandomObject()); + randomList.push("key", "value"); + assertEquals(((RandomElement.RandomObject) randomList.get(0)).get("key"), "value"); + } + + @Test + public void testRandomObjectPushKeyValue() { + randomObject.push("key", "value"); + assertEquals(randomObject.get("key"), "value"); + } + + @Test + public void testRandomObjectPushRandomObject() { + RandomElement.RandomObject nestedObject = new RandomElement.RandomObject(); + nestedObject.push("nestedKey", "nestedValue"); + randomObject.push(nestedObject); + assertEquals(randomObject.size(), 1); + assertEquals(randomObject.get("nestedKey"), "nestedValue"); + } + + @Test(expectedExceptions = UnsupportedOperationException.class) + public void testRandomObjectPushValueThrowsException() { + randomObject.push("value"); + } + + @Test + public void testRandomValuePushValue() { + randomValue.push("testValue"); + assertEquals(randomValue.getValue(), "testValue"); + } + + @Test + public void testRandomValuePushRandomElement() { + RandomElement.RandomObject nestedObject = new RandomElement.RandomObject(); + randomValue = new RandomElement.RandomValue(nestedObject); + randomValue.push("key", "value"); + assertEquals(((RandomElement.RandomObject) randomValue.getValue()).get("key"), "value"); + } + + @Test(expectedExceptions = IllegalStateException.class) + public void testRandomValuePushKeyValueThrowsException() { + randomValue.push("key", "value"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java new file mode 100644 index 0000000000..8d4d7a14e9 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomEnumGeneratorTest.java @@ -0,0 +1,77 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomEnumGeneratorTest { + + private RandomEnumGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder mockBuilder; + private OasSchema mockSchema; + + @BeforeMethod + public void setUp() { + generator = new RandomEnumGenerator(); + mockContext = mock(RandomContext.class); + mockBuilder = mock(RandomModelBuilder.class); + mockSchema = mock(OasSchema.class); + + when(mockContext.getRandomModelBuilder()).thenReturn(mockBuilder); + } + + @Test + public void testHandlesWithEnum() { + mockSchema.enum_ = List.of("value1", "value2", "value3"); + + boolean result = generator.handles(mockSchema); + + assertTrue(result); + } + + @Test + public void testHandlesWithoutEnum() { + mockSchema.enum_ = null; + + boolean result = generator.handles(mockSchema); + + assertFalse(result); + } + + @Test + public void testGenerateWithEnum() { + mockSchema.enum_ = List.of("value1", "value2", "value3"); + + generator.generate(mockContext, mockSchema); + + verify(mockBuilder).appendSimpleQuoted("citrus:randomEnumValue('value1','value2','value3')"); + } + + @Test + public void testGenerateWithEmptyEnum() { + mockSchema.enum_ = List.of(); + + generator.generate(mockContext, mockSchema); + + verify(mockBuilder).appendSimpleQuoted("citrus:randomEnumValue()"); + } + + @Test + public void testGenerateWithNullEnum() { + mockSchema.enum_ = null; + + generator.generate(mockContext, mockSchema); + + verify(mockBuilder, never()).appendSimpleQuoted(anyString()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java new file mode 100644 index 0000000000..01d1c253e6 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorBuilderTest.java @@ -0,0 +1,92 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import java.util.function.BiConsumer; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + + +public class RandomGeneratorBuilderTest { + + private BiConsumer consumerMock; + private RandomContext contextMock; + private OasSchema schemaMock; + + @BeforeMethod + public void setUp() { + consumerMock = mock(); + contextMock = mock(); + schemaMock = mock(); + } + + @Test + public void testBuilderWithTypeAndFormat() { + String type = "type1"; + String format = "format1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder(type, format).build(consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.type, type); + assertEquals(schema.format, format); + } + + @Test + public void testBuilderWithType() { + String type = "type1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder().withType(type).build( + consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.type, type); + } + + @Test + public void testBuilderWithFormat() { + String format = "format1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder().withFormat(format).build( + consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.format, format); + } + + @Test + public void testBuilderWithPattern() { + String pattern = "pattern1"; + + RandomGenerator generator = RandomGeneratorBuilder.builder().withPattern(pattern).build( + consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertEquals(schema.pattern, pattern); + } + + @Test + public void testBuilderWithEnum() { + RandomGenerator generator = RandomGeneratorBuilder.builder().withEnum().build(consumerMock); + OasSchema schema = (OasSchema) ReflectionTestUtils.getField(generator, "schema"); + assertNotNull(schema); + assertNotNull(schema.enum_); + assertTrue(schema.enum_.isEmpty()); + } + + @Test + public void testBuildGenerator() { + RandomGenerator generator = RandomGeneratorBuilder.builder().build(consumerMock); + + generator.generate(contextMock, schemaMock); + + verify(consumerMock).accept(contextMock, schemaMock); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java new file mode 100644 index 0000000000..c788fff730 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomGeneratorTest.java @@ -0,0 +1,152 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomGeneratorTest { + + private RandomGenerator generator; + private OasSchema mockSchema; + + @BeforeMethod + public void setUp() { + mockSchema = mock(OasSchema.class); + generator = new RandomGenerator(mockSchema) { + @Override + void generate(RandomContext randomContext, OasSchema schema) { + // Implementation not needed for this test + } + }; + } + + @Test + public void testHandlesWithMatchingTypeAndFormat() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format1"; + + mockSchema.type = "type1"; + mockSchema.format = "format1"; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithTypeAny() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format1"; + + mockSchema.type = RandomGenerator.ANY; + mockSchema.format = "format1"; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithFormatAny() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format1"; + + mockSchema.type = "type1"; + mockSchema.format = RandomGenerator.ANY; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithPatternAny() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.pattern = "pattern1"; + + mockSchema.type = "type1"; + mockSchema.pattern = RandomGenerator.ANY; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithMatchingPattern() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.pattern = "pattern1"; + + mockSchema.type = "type1"; + mockSchema.pattern = "pattern1"; + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithMatchingEnum() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.enum_ = List.of("value1", "value2"); + + mockSchema.type = "type1"; + mockSchema.enum_ = List.of("value1", "value2"); + + assertTrue(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithNonMatchingType() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type2"; + otherSchema.format = "format1"; + + mockSchema.type = "type1"; + mockSchema.format = "format1"; + + assertFalse(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithNonMatchingFormat() { + OasSchema otherSchema = new Oas30Schema(); + otherSchema.type = "type1"; + otherSchema.format = "format2"; + + mockSchema.type = "type1"; + mockSchema.format = "format1"; + + assertFalse(generator.handles(otherSchema)); + } + + @Test + public void testHandlesWithNullSchema() { + assertFalse(generator.handles(null)); + } + + @Test + public void testHandlesWithNullGeneratorSchema() { + RandomGenerator generatorWithNullSchema = new RandomGenerator() { + @Override + void generate(RandomContext randomContext, OasSchema schema) { + // Do nothing + } + }; + + assertFalse(generatorWithNullSchema.handles(mockSchema)); + } + + @Test + public void testNullGenerator() { + RandomContext mockContext = mock(RandomContext.class); + + RandomGenerator.NULL_GENERATOR.generate(mockContext, mockSchema); + + verify(mockContext, never()).getRandomModelBuilder(); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomModelBuilderTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomModelBuilderTest.java new file mode 100644 index 0000000000..ef4deb3abc --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomModelBuilderTest.java @@ -0,0 +1,153 @@ +/* + * 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 static org.testng.Assert.assertEquals; +import static org.testng.Assert.expectThrows; + +import java.util.ArrayDeque; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomModelBuilderTest { + + private RandomModelBuilder builder; + + @BeforeMethod + public void setUp() { + builder = new RandomModelBuilder(true); + } + + @Test + public void testInitialState() { + String text = builder.write(); + assertEquals(text, ""); + } + + @Test + public void testAppendSimple() { + builder.appendSimple("testValue"); + String json = builder.write(); + assertEquals(json, "testValue"); + } + + @Test + public void testAppendSimpleToEmptyQueue() { + ReflectionTestUtils.setField(builder, "deque", new ArrayDeque<>()); + builder.appendSimple("testValue"); + String json = builder.write(); + assertEquals(json, "testValue"); + } + + @Test + public void testAppendSimpleQuoted() { + builder.appendSimpleQuoted("testValue"); + String json = builder.write(); + assertEquals(json, "\"testValue\""); + } + + @Test + public void testAppendSimpleQuotedIfNotQuoting() { + ReflectionTestUtils.setField(builder,"quote", false); + builder.appendSimpleQuoted("testValue"); + String json = builder.write(); + assertEquals(json, "testValue"); + } + + @Test + public void testObjectWithProperties() { + builder.object(() -> { + builder.property("key1", () -> builder.appendSimple("\"value1\"")); + builder.property("key2", () -> builder.appendSimple("\"value2\"")); + }); + String json = builder.write(); + assertEquals(json, "{\"key1\": \"value1\",\"key2\": \"value2\"}"); + } + + @Test + public void testNestedObject() { + builder.object(() -> + builder.property("outerKey", () -> builder.object(() -> + builder.property("innerKey", () -> builder.appendSimple("\"innerValue\"")) + )) + ); + String json = builder.write(); + assertEquals(json, "{\"outerKey\": {\"innerKey\": \"innerValue\"}}"); + } + + @Test + public void testArray() { + builder.array(() -> { + builder.appendSimple("\"value1\""); + builder.appendSimple("\"value2\""); + builder.appendSimple("\"value3\""); + }); + String json = builder.write(); + assertEquals(json, "[\"value1\",\"value2\",\"value3\"]"); + } + + @Test + public void testNestedArray() { + builder.array(() -> { + builder.appendSimple("\"value1\""); + builder.array(() -> { + builder.appendSimple("\"nestedValue1\""); + builder.appendSimple("\"nestedValue2\""); + }); + builder.appendSimple("\"value2\""); + }); + String json = builder.write(); + assertEquals(json, "[\"value1\",[\"nestedValue1\",\"nestedValue2\"],\"value2\"]"); + } + + @Test + public void testMixedStructure() { + builder.object(() -> { + builder.property("key1", () -> builder.array(() -> { + builder.appendSimple("\"value1\""); + builder.object(() -> + builder.property("nestedKey", () -> builder.appendSimple("\"nestedValue\"")) + ); + })); + builder.property("key2", () -> builder.appendSimple("\"value2\"")); + }); + String json = builder.write(); + assertEquals(json, "{\"key1\": [\"value1\",{\"nestedKey\": \"nestedValue\"}],\"key2\": \"value2\"}"); + } + + @Test + public void testIllegalStateOnEmptyDeque() { + + builder.deque.clear(); + + Exception exception = expectThrows(IllegalStateException.class, () -> + builder.property("key", () -> builder.appendSimple("value")) + ); + assertEquals(exception.getMessage(), "Encountered empty stack!"); + + exception = expectThrows(IllegalStateException.class, () -> + builder.object(() -> {}) + ); + assertEquals(exception.getMessage(), "Encountered empty stack!"); + + exception = expectThrows(IllegalStateException.class, () -> + builder.array(() -> {}) + ); + assertEquals(exception.getMessage(), "Encountered empty stack!"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java new file mode 100644 index 0000000000..fbcb4f0479 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomNumberGeneratorTest.java @@ -0,0 +1,217 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.math.BigDecimal; +import org.citrusframework.openapi.OpenApiConstants; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class RandomNumberGeneratorTest { + + private RandomNumberGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder mockBuilder; + private OasSchema schema; + + @BeforeMethod + public void setUp() { + generator = new RandomNumberGenerator(); + mockContext = mock(RandomContext.class); + mockBuilder = mock(RandomModelBuilder.class); + schema = new Oas30Schema(); + + when(mockContext.getRandomModelBuilder()).thenReturn(mockBuilder); + } + + @Test + public void testGenerateDefaultBounds() { + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '-1000', '1000', 'false', 'false')"); + } + + @Test + public void testGenerateWithMinimum() { + schema.minimum = BigDecimal.valueOf(5); + generator.generate(mockContext, schema); + // Max is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '5', '1005', 'false', 'false')"); + } + + @Test + public void testGenerateWithMaximum() { + schema.maximum = BigDecimal.valueOf(15); + generator.generate(mockContext, schema); + // Min is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '-985', '15', 'false', 'false')"); + } + + @Test + public void testGenerateWithMinimumAndMaximum() { + schema.minimum = BigDecimal.valueOf(5); + schema.maximum = BigDecimal.valueOf(15); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '5', '15', 'false', 'false')"); + } + + @Test + public void testGenerateWithExclusiveMinimum() { + schema.minimum = BigDecimal.valueOf(5); + schema.exclusiveMinimum = true; + generator.generate(mockContext, schema); + // Max is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '5', '1005', 'true', 'false')"); + } + + @Test + public void testGenerateWithExclusiveMaximum() { + schema.maximum = BigDecimal.valueOf(15); + schema.exclusiveMaximum = true; + generator.generate(mockContext, schema); + // Min is because of guessing a reasonable range + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '-985', '15', 'false', 'true')"); + } + + @Test + public void testGenerateWithMultipleOf() { + schema.multipleOf = BigDecimal.valueOf(5); + schema.minimum = BigDecimal.valueOf(10); + schema.maximum = BigDecimal.valueOf(50); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('0', '10', '50', 'false', 'false', '5')"); + } + + @Test + public void testGenerateWithIntegerType() { + schema.type = "integer"; + schema.minimum = BigDecimal.valueOf(1); + schema.maximum = BigDecimal.valueOf(10); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('0', '1', '10', 'false', 'false')"); + } + + @Test + public void testGenerateWithFloatType() { + schema.type = "number"; + schema.minimum = BigDecimal.valueOf(1.5); + schema.maximum = BigDecimal.valueOf(10.5); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('2', '1.5', '10.5', 'false', 'false')"); + } + + @Test + public void testGenerateWithMultipleOfFloat() { + schema.type = "number"; + schema.multipleOf = BigDecimal.valueOf(0.5); + schema.minimum = BigDecimal.valueOf(1.0); + schema.maximum = BigDecimal.valueOf(5.0); + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimple("citrus:randomNumberGenerator('1', '1.0', '5.0', 'false', 'false', '0.5')"); + } + + @Test + public void testCalculateMinRelativeToMaxWithMultipleOf() { + BigDecimal max = new BigDecimal("1000"); + Number multipleOf = new BigDecimal("10"); + + BigDecimal result = RandomNumberGenerator.calculateMinRelativeToMax(max, multipleOf); + + BigDecimal expected = max.subtract(new BigDecimal(multipleOf.toString()).abs().multiply(RandomNumberGenerator.HUNDRED)); + assertEquals(result, expected); + } + + @Test + public void testCalculateMinRelativeToMaxWithoutMultipleOf() { + BigDecimal max = new BigDecimal("1000"); + + BigDecimal result = RandomNumberGenerator.calculateMinRelativeToMax(max, null); + + BigDecimal expected = max.subtract(max.multiply(BigDecimal.valueOf(2)).max(RandomNumberGenerator.THOUSAND)); + assertEquals(result, expected); + } + + @Test + public void testCalculateMaxRelativeToMinWithMultipleOf() { + BigDecimal min = new BigDecimal("1000"); + Number multipleOf = new BigDecimal("10"); + + BigDecimal result = RandomNumberGenerator.calculateMaxRelativeToMin(min, multipleOf); + + BigDecimal expected = min.add(new BigDecimal(multipleOf.toString()).abs().multiply(RandomNumberGenerator.HUNDRED)); + assertEquals(result, expected); + } + + @Test + public void testCalculateMaxRelativeToMinWithoutMultipleOf() { + BigDecimal min = new BigDecimal("1000"); + + BigDecimal result = RandomNumberGenerator.calculateMaxRelativeToMin(min, null); + + BigDecimal expected = min.add(min.multiply(BigDecimal.valueOf(2)).max(RandomNumberGenerator.THOUSAND)); + assertEquals(result, expected); + } + + @Test + public void testHandlesWithIntegerType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_INTEGER; + + assertTrue(generator.handles(schema)); + } + + @Test + public void testHandlesWithNumberType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_NUMBER; + + assertTrue(generator.handles(schema)); + } + + @Test + public void testHandlesWithOtherType() { + OasSchema schema = new Oas30Schema(); + schema.type = "string"; + + assertFalse(generator.handles(schema)); + } + + @Test + public void testHandlesWithNullType() { + OasSchema schema = new Oas30Schema(); + schema.type = null; + + assertFalse(generator.handles(schema)); + } + + @Test + public void testHandlesWithNullSchema() { + assertFalse(generator.handles(null)); + } + + @DataProvider(name = "findLeastSignificantDecimalPlace") + public static Object[][] findLeastSignificantDecimalPlace() { + return new Object[][]{ + {new BigDecimal("1234.5678"), 4}, + {new BigDecimal("123.567"), 3}, + {new BigDecimal("123.56"), 2}, + {new BigDecimal("123.5"), 1}, + {new BigDecimal("123.0"), 0}, + {new BigDecimal("123"), 0} + }; + } + + @Test(dataProvider = "findLeastSignificantDecimalPlace") + void findLeastSignificantDecimalPlace(BigDecimal number, int expectedSignificance) { + assertEquals(generator.findLeastSignificantDecimalPlace(number), + expectedSignificance); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java new file mode 100644 index 0000000000..a32809f2f9 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomObjectGeneratorTest.java @@ -0,0 +1,134 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import org.citrusframework.openapi.OpenApiConstants; +import org.citrusframework.openapi.OpenApiSpecification; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomObjectGeneratorTest { + + private RandomObjectGenerator generator; + private RandomContext contextMock; + private RandomModelBuilder randomModelBuilderSpy; + private OpenApiSpecification specificationMock; + + @BeforeMethod + public void setUp() { + generator = new RandomObjectGenerator(); + contextMock = mock(); + specificationMock = mock(); + + randomModelBuilderSpy = spy(new RandomModelBuilder(true)); + when(contextMock.getRandomModelBuilder()).thenReturn(randomModelBuilderSpy); + when(contextMock.getSpecification()).thenReturn(specificationMock); + when(contextMock.get(eq("OBJECT_STACK"), any())).thenReturn(new ArrayDeque<>()); + + } + + @Test + public void testHandlesObjectType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + + assertTrue(generator.handles(schema)); + } + + @Test + public void testDoesNotHandleNonObjectType() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_STRING; + + assertFalse(generator.handles(schema)); + } + + @Test + public void testGenerateObjectWithoutProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + } + + @Test + public void testGenerateObjectWithProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + schema.properties = new HashMap<>(); + OasSchema propertySchema = new Oas30Schema(); + schema.properties.put("property1", propertySchema); + + when(specificationMock.isGenerateOptionalFields()).thenReturn(true); + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + verify(randomModelBuilderSpy).property(eq("property1"), any()); + verify(contextMock).generate(propertySchema); + } + + @Test + public void testGenerateObjectWithRequiredProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + schema.properties = new HashMap<>(); + OasSchema propertySchema = new Oas30Schema(); + schema.properties.put("property1", propertySchema); + schema.required = List.of("property1"); + + when(specificationMock.isGenerateOptionalFields()).thenReturn(false); + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + verify(randomModelBuilderSpy).property(eq("property1"), any()); + verify(contextMock).generate(propertySchema); + } + + @Test + public void testGenerateObjectWithOptionalProperties() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + schema.properties = new HashMap<>(); + OasSchema propertySchema = new Oas30Schema(); + schema.properties.put("property1", propertySchema); + schema.required = List.of(); + when(specificationMock.isGenerateOptionalFields()).thenReturn(false); + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy).object(any()); + verify(randomModelBuilderSpy, never()).property(eq("property1"), any()); + verify(contextMock, never()).generate(propertySchema); + } + + @Test + public void testGenerateObjectWithRecursion() { + OasSchema schema = new Oas30Schema(); + schema.type = OpenApiConstants.TYPE_OBJECT; + Deque objectStack = new ArrayDeque<>(); + objectStack.push(schema); + + when(contextMock.get(eq("OBJECT_STACK"), any())).thenReturn(objectStack); + + generator.generate(contextMock, schema); + + verify(randomModelBuilderSpy, never()).object(any()); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java new file mode 100644 index 0000000000..445a318bd9 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/random/RandomStringGeneratorTest.java @@ -0,0 +1,70 @@ +package org.citrusframework.openapi.random; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.apicurio.datamodels.openapi.models.OasSchema; +import io.apicurio.datamodels.openapi.v3.models.Oas30Schema; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class RandomStringGeneratorTest { + + private RandomStringGenerator generator; + private RandomContext mockContext; + private RandomModelBuilder mockBuilder; + private OasSchema schema; + + @BeforeMethod + public void setUp() { + generator = new RandomStringGenerator(); + mockContext = mock(RandomContext.class); + mockBuilder = mock(RandomModelBuilder.class); + schema = new Oas30Schema(); + + when(mockContext.getRandomModelBuilder()).thenReturn(mockBuilder); + } + + @Test + public void testGenerateDefaultLength() { + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,1)"); + } + + @Test + public void testGenerateWithMinLength() { + schema.minLength = 5; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,5)"); + } + + @Test + public void testGenerateWithMaxLength() { + schema.maxLength = 15; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(15,MIXED,true,1)"); + } + + @Test + public void testGenerateWithMinAndMaxLength() { + schema.minLength = 3; + schema.maxLength = 8; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(8,MIXED,true,3)"); + } + + @Test + public void testGenerateWithZeroMinLength() { + schema.minLength = 0; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,1)"); + } + + @Test + public void testGenerateWithZeroMaxLength() { + schema.maxLength = 0; + generator.generate(mockContext, schema); + verify(mockBuilder).appendSimpleQuoted("citrus:randomString(10,MIXED,true,1)"); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java new file mode 100644 index 0000000000..04a0d47086 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidationProcessorTest.java @@ -0,0 +1,123 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertNotNull; + +import java.util.Optional; +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OpenApiRequestValidationProcessorTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + private OpenApiRequestValidationProcessor processor; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + processor = new OpenApiRequestValidationProcessor(openApiSpecificationMock, "operationId"); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateNonHttpMessage() { + Message messageMock = mock(); + + processor.validate(messageMock, mock()); + + verify(openApiSpecificationMock,times(2)).getSwaggerOpenApiValidationContext(); + verifyNoMoreInteractions(openApiSpecificationMock); + } + + @Test + public void shouldValidateHttpMessage() { + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + OpenApiRequestValidator openApiRequestValidatorSpy = replaceValidatorWithSpy(httpMessageMock); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.of(operationPathAdapterMock)); + + processor.validate(httpMessageMock, contextMock); + + verify(openApiRequestValidatorSpy).validateRequest(operationPathAdapterMock, httpMessageMock); + } + + @Test + public void shouldCallValidateRequest() { + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + OpenApiRequestValidator openApiRequestValidatorSpy = replaceValidatorWithSpy(httpMessageMock); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.empty()); + + processor.validate(httpMessageMock, contextMock); + + verify(openApiSpecificationMock).getOperation(anyString(), + any(TestContext.class)); + verify(openApiRequestValidatorSpy, times(0)).validateRequest(operationPathAdapterMock, httpMessageMock); + } + + private OpenApiRequestValidator replaceValidatorWithSpy(HttpMessage httpMessage) { + OpenApiRequestValidator openApiRequestValidator = (OpenApiRequestValidator) ReflectionTestUtils.getField( + processor, + "openApiRequestValidator"); + + assertNotNull(openApiRequestValidator); + OpenApiRequestValidator openApiRequestValidatorSpy = spy(openApiRequestValidator); + ReflectionTestUtils.setField(processor, "openApiRequestValidator", openApiRequestValidatorSpy); + + doAnswer((invocation) -> null + // do nothing + ).when(openApiRequestValidatorSpy).validateRequest(operationPathAdapterMock, httpMessage); + + return openApiRequestValidatorSpy; + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java new file mode 100644 index 0000000000..3716b503c7 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiRequestValidatorTest.java @@ -0,0 +1,170 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.Request.Method; +import com.atlassian.oai.validator.report.ValidationReport; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.http.message.HttpMessageHeaders; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.web.bind.annotation.RequestMethod; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OpenApiRequestValidatorTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private SwaggerOpenApiValidationContext swaggerOpenApiValidationContextMock; + + @Mock + private OpenApiInteractionValidator openApiInteractionValidatorMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + @Mock + private HttpMessage httpMessageMock; + + @Mock + private ValidationReport validationReportMock; + + private OpenApiRequestValidator openApiRequestValidator; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + + doReturn(swaggerOpenApiValidationContextMock).when(openApiSpecificationMock).getSwaggerOpenApiValidationContext(); + doReturn(openApiInteractionValidatorMock).when(swaggerOpenApiValidationContextMock).getOpenApiInteractionValidator(); + + openApiRequestValidator = new OpenApiRequestValidator(openApiSpecificationMock); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateWhenDisabled() { + // Given + openApiRequestValidator.setEnabled(false); + // When + openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); + // Then + Assert.assertFalse(openApiRequestValidator.isEnabled()); + verify(openApiInteractionValidatorMock, never()).validateRequest(any(Request.class)); + } + + @Test + public void shouldValidateRequestWithNoErrors() { + // Given + openApiRequestValidator.setEnabled(true); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/test"); + when(httpMessageMock.getRequestMethod()).thenReturn(RequestMethod.GET); + when(openApiInteractionValidatorMock.validateRequest(any(Request.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(false); + + // When + openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock).validateRequest(any(Request.class)); + verify(validationReportMock).hasErrors(); + } + + @Test(expectedExceptions = ValidationException.class) + public void shouldValidateRequestWithErrors() { + // Given + openApiRequestValidator.setEnabled(true); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/test"); + when(httpMessageMock.getRequestMethod()).thenReturn(RequestMethod.GET); + when(openApiInteractionValidatorMock.validateRequest(any(Request.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(true); + + // When + openApiRequestValidator.validateRequest(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock).validateRequest(any(Request.class)); + verify(validationReportMock).hasErrors(); + } + + @Test + public void shouldCreateRequestFromMessage() throws IOException { + // Given + when(httpMessageMock.getPayload()).thenReturn("payload"); + + Map headers = new HashMap<>(); + headers.put("array", List.of("e1", "e2")); + headers.put("nullarray", null); + headers.put("simple", "s1"); + + when(httpMessageMock.getHeaders()).thenReturn(headers); + when(httpMessageMock.getHeader(HttpMessageHeaders.HTTP_REQUEST_URI)).thenReturn("/api/test"); + when(httpMessageMock.getRequestMethod()).thenReturn(RequestMethod.GET); + when(httpMessageMock.getAccept()).thenReturn("application/json"); + when(operationPathAdapterMock.contextPath()).thenReturn("/api"); + + // When + Request request = openApiRequestValidator.createRequestFromMessage(operationPathAdapterMock, httpMessageMock); + + // Then + assertNotNull(request); + assertEquals(request.getPath(), "/test"); + assertEquals(request.getMethod(), Method.GET); + assertEquals(request.getHeaders().get("array"), List.of("e1", "e2")); + assertEquals(request.getHeaders().get("simple"), List.of("s1")); + List nullList = new ArrayList<>(); + nullList.add(null); + assertEquals(request.getHeaders().get("nullarray"), nullList); + assertTrue(request.getRequestBody().isPresent()); + + assertEquals(request.getRequestBody().get().toString(StandardCharsets.UTF_8), "payload"); + } + +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java new file mode 100644 index 0000000000..2058af2558 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidationProcessorTest.java @@ -0,0 +1,123 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertNotNull; + +import java.util.Optional; +import org.citrusframework.context.TestContext; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.message.Message; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OpenApiResponseValidationProcessorTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + private OpenApiResponseValidationProcessor processor; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + processor = new OpenApiResponseValidationProcessor(openApiSpecificationMock, "operationId"); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateNonHttpMessage() { + Message messageMock = mock(); + + processor.validate(messageMock, mock()); + + verify(openApiSpecificationMock,times(2)).getSwaggerOpenApiValidationContext(); + verifyNoMoreInteractions(openApiSpecificationMock); + } + + @Test + public void shouldCallValidateResponse() { + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + OpenApiResponseValidator openApiResponseValidatorSpy = replaceValidatorWithSpy(httpMessageMock); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.of(operationPathAdapterMock)); + + processor.validate(httpMessageMock, contextMock); + + verify(openApiResponseValidatorSpy).validateResponse(operationPathAdapterMock, httpMessageMock); + } + + @Test + public void shouldNotValidateWhenNoOperation() { + HttpMessage httpMessageMock = mock(); + TestContext contextMock = mock(); + + OpenApiResponseValidator openApiResponseValidatorSpy = replaceValidatorWithSpy(httpMessageMock); + + when(openApiSpecificationMock.getOperation(anyString(), any(TestContext.class))) + .thenReturn(Optional.empty()); + + processor.validate(httpMessageMock, contextMock); + + verify(openApiSpecificationMock).getOperation(anyString(), + any(TestContext.class)); + verify(openApiResponseValidatorSpy, times(0)).validateResponse(operationPathAdapterMock, httpMessageMock); + } + + private OpenApiResponseValidator replaceValidatorWithSpy(HttpMessage httpMessage) { + OpenApiResponseValidator openApiResponseValidator = (OpenApiResponseValidator) ReflectionTestUtils.getField( + processor, + "openApiResponseValidator"); + + assertNotNull(openApiResponseValidator); + OpenApiResponseValidator openApiResponseValidatorSpy = spy(openApiResponseValidator); + ReflectionTestUtils.setField(processor, "openApiResponseValidator", openApiResponseValidatorSpy); + + doAnswer((invocation) -> null + // do nothing + ).when(openApiResponseValidatorSpy).validateResponse(operationPathAdapterMock, httpMessage); + + return openApiResponseValidatorSpy; + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java new file mode 100644 index 0000000000..cfccf76d92 --- /dev/null +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/validation/OpenApiResponseValidatorTest.java @@ -0,0 +1,164 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.openapi.validation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request.Method; +import com.atlassian.oai.validator.model.Response; +import com.atlassian.oai.validator.report.ValidationReport; +import io.apicurio.datamodels.openapi.models.OasOperation; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.http.message.HttpMessage; +import org.citrusframework.openapi.OpenApiSpecification; +import org.citrusframework.openapi.model.OperationPathAdapter; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatusCode; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OpenApiResponseValidatorTest { + + @Mock + private OpenApiSpecification openApiSpecificationMock; + + @Mock + private SwaggerOpenApiValidationContext swaggerOpenApiValidationContextMock; + + @Mock + private OpenApiInteractionValidator openApiInteractionValidatorMock; + + @Mock + private OasOperation operationMock; + + @Mock + private OperationPathAdapter operationPathAdapterMock; + + @Mock + private HttpMessage httpMessageMock; + + @Mock + private ValidationReport validationReportMock; + + @InjectMocks + private OpenApiResponseValidator openApiResponseValidator; + + private AutoCloseable mockCloseable; + + @BeforeMethod + public void beforeMethod() { + mockCloseable = MockitoAnnotations.openMocks(this); + + doReturn(swaggerOpenApiValidationContextMock).when(openApiSpecificationMock).getSwaggerOpenApiValidationContext(); + doReturn(openApiInteractionValidatorMock).when(swaggerOpenApiValidationContextMock).getOpenApiInteractionValidator(); + + openApiResponseValidator = new OpenApiResponseValidator(openApiSpecificationMock); + } + + @AfterMethod + public void afterMethod() throws Exception { + mockCloseable.close(); + } + + @Test + public void shouldNotValidateWhenDisabled() { + // Given + openApiResponseValidator.setEnabled(false); + // When + openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); + // Then + Assert.assertFalse(openApiResponseValidator.isEnabled()); + verify(openApiInteractionValidatorMock, never()).validateResponse(anyString(), any(Method.class), any(Response.class)); + } + + @Test + public void shouldValidateWithNoErrors() { + // Given + openApiResponseValidator.setEnabled(true); + when(openApiInteractionValidatorMock.validateResponse(anyString(), any(Method.class), any(Response.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(false); + + when(operationPathAdapterMock.operation()).thenReturn(operationMock); + when(operationPathAdapterMock.apiPath()).thenReturn("/api/path"); + when(operationMock.getMethod()).thenReturn("get"); + when(httpMessageMock.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // When + openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock).validateResponse(anyString(), any(Method.class), any(Response.class)); + verify(validationReportMock).hasErrors(); + } + + @Test(expectedExceptions = ValidationException.class) + public void shouldValidateWithErrors() { + // Given + openApiResponseValidator.setEnabled(true); + when(openApiInteractionValidatorMock.validateResponse(anyString(), any(Method.class), any(Response.class))) + .thenReturn(validationReportMock); + when(validationReportMock.hasErrors()).thenReturn(true); + + when(operationPathAdapterMock.operation()).thenReturn(operationMock); + when(operationPathAdapterMock.apiPath()).thenReturn("/api/path"); + when(operationMock.getMethod()).thenReturn("get"); + when(httpMessageMock.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // When + openApiResponseValidator.validateResponse(operationPathAdapterMock, httpMessageMock); + + // Then + verify(openApiInteractionValidatorMock).validateResponse(anyString(), any(Method.class), any(Response.class)); + verify(validationReportMock).hasErrors(); + } + + @Test + public void shouldCreateResponseMessage() throws IOException { + // Given + when(httpMessageMock.getPayload()).thenReturn("payload"); + when(httpMessageMock.getHeaders()).thenReturn(Map.of("Content-Type", "application/json")); + when(httpMessageMock.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // When + Response response = openApiResponseValidator.createResponseFromMessage(httpMessageMock, 200); + + // Then + assertNotNull(response); + assertTrue(response.getResponseBody().isPresent()); + assertEquals(response.getResponseBody().get().toString(StandardCharsets.UTF_8), "payload"); + assertTrue(response.getHeaderValue("Content-Type").isPresent()); + assertEquals(response.getHeaderValue("Content-Type").get(), "application/json"); + assertEquals(response.getStatus(), Integer.valueOf(200)); + } +} diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java index dcb704ebf6..500a559eeb 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiClientTest.java @@ -16,11 +16,12 @@ package org.citrusframework.openapi.xml; +import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; + import java.io.IOException; import java.util.Map; import java.util.Queue; import java.util.concurrent.ArrayBlockingQueue; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -60,8 +61,6 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; -import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; - /** * @author Christoph Deppisch */ @@ -127,7 +126,9 @@ public void shouldLoadOpenApiClientActions() throws IOException { context.getReferenceResolver().bind("httpClient", httpClient); context.getReferenceResolver().bind("httpServer", httpServer); - responses.add(new HttpMessage(FileUtils.readToString(Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.yaml")))); + String apiAsString = FileUtils.readToString(Resources.create("classpath:org/citrusframework/openapi/petstore/petstore-v3.yaml")); + responses.add(new HttpMessage(apiAsString)); + responses.add(new HttpMessage(apiAsString)); responses.add(new HttpMessage(""" { "id": 1000, @@ -250,4 +251,5 @@ public void shouldLookupTestActionBuilder() { Assert.assertTrue(XmlTestActionBuilder.lookup("openapi").isPresent()); Assert.assertEquals(XmlTestActionBuilder.lookup("openapi").get().getClass(), OpenApi.class); } + } diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java index b90685f1e4..4c6fb72246 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/xml/OpenApiServerTest.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.xml; -import java.util.Map; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -45,6 +43,8 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.util.Map; + import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; @@ -159,9 +159,10 @@ public void shouldLoadOpenApiServerActions() { Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "application/json"); + Assert.assertNull(sendMessageAction.getEndpoint()); Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); - Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 0); + Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 1); receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java index a6c383d93a..d8a338ef9e 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiClientTest.java @@ -16,11 +16,6 @@ package org.citrusframework.openapi.yaml; -import java.io.IOException; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ArrayBlockingQueue; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -58,6 +53,11 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.io.IOException; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; + import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; /** diff --git a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java index 02291ad91d..e9e6224403 100644 --- a/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java +++ b/connectors/citrus-openapi/src/test/java/org/citrusframework/openapi/yaml/OpenApiServerTest.java @@ -16,8 +16,6 @@ package org.citrusframework.openapi.yaml; -import java.util.Map; - import org.citrusframework.TestActor; import org.citrusframework.TestCase; import org.citrusframework.TestCaseMetaInfo; @@ -45,6 +43,8 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; +import java.util.Map; + import static org.citrusframework.endpoint.direct.DirectEndpoints.direct; import static org.citrusframework.http.endpoint.builder.HttpEndpoints.http; @@ -159,9 +159,10 @@ public void shouldLoadOpenApiServerActions() { Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_STATUS_CODE), 200); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_REASON_PHRASE), "OK"); Assert.assertEquals(httpMessageBuilder.getMessage().getHeaders().get(HttpMessageHeaders.HTTP_CONTENT_TYPE), "application/json"); + Assert.assertNull(sendMessageAction.getEndpoint()); Assert.assertEquals(sendMessageAction.getEndpointUri(), "httpServer"); - Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 0); + Assert.assertEquals(sendMessageAction.getMessageProcessors().size(), 1); receiveMessageAction = (ReceiveMessageAction) result.getTestAction(actionIndex++); Assert.assertEquals(receiveMessageAction.getValidationContexts().size(), 3); diff --git a/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor b/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor new file mode 100644 index 0000000000..1092e9da72 --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/META-INF/citrus/openapi/processor/sampleOpenApiProcessor @@ -0,0 +1,2 @@ +name=sampleOpenApiProcessor +type=org.citrusframework.openapi.SampleOpenApiProcessor diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json new file mode 100644 index 0000000000..c265dff5be --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/pet_invalid.json @@ -0,0 +1,15 @@ +{ + "id": ${petId}, + "category": { + "id": ${petId}, + "name": "citrus:randomEnumValue('dog', 'cat', 'fish')" + }, + "photoUrls": [ "http://localhost:8080/photos/${petId}" ], + "tags": [ + { + "id": ${petId}, + "name": "generated" + } + ], + "status": "citrus:randomEnumValue('available', 'pending', 'sold')" +} diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json index 618854948f..a7e135c535 100644 --- a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/petstore/petstore-v3.json @@ -98,7 +98,8 @@ "description": "ID of pet to return", "schema": { "format": "int64", - "type": "integer" + "type": "integer", + "minimum": 1 }, "in": "path", "required": true @@ -158,7 +159,8 @@ "description": "Pet id to delete", "schema": { "format": "int64", - "type": "integer" + "type": "integer", + "minimum": 1 }, "in": "path", "required": true diff --git a/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml new file mode 100644 index 0000000000..29bda14fb2 --- /dev/null +++ b/connectors/citrus-openapi/src/test/resources/org/citrusframework/openapi/ping/ping-api.yaml @@ -0,0 +1,467 @@ +openapi: 3.0.1 +info: + title: Ping API + description: 'A simple OpenApi defining schemas for testing purposes' + version: 1.0 + +servers: + - url: http://localhost:9000/services/rest/ping/v1 + - url: http://localhost:9000/ping/v1 + +paths: + /ping/{id}: + put: + tags: + - ping + summary: Do the ping + operationId: doPing + parameters: + - name: id + in: path + description: Id to ping + required: true + schema: + type: integer + format: int64 + - name: q1 + in: query + description: Some queryParameter + required: true + schema: + type: integer + format: int64 + - name: api-key + in: header + description: Some header + required: true + schema: + type: string + requestBody: + description: Ping data + content: + application/json: + schema: + $ref: '#/components/schemas/PingReqType' + required: true + responses: + 200: + description: successful operation + headers: + ping-time: + required: false + description: response time + schema: + type: integer + format: int64 + content: + application/json: + schema: + $ref: '#/components/schemas/PingRespType' + plain/text: + schema: + type: string + 405: + description: Some error + content: + text/plain: + schema: + type: string + /pong/{id}: + get: + tags: + - pong + summary: Do the pong + operationId: doPong + parameters: + - name: id + in: path + description: Id to pong + required: true + explode: true + schema: + type: integer + format: int64 + responses: + 200: + description: successful operation without a response + /pung/{id}: + get: + tags: + - pong + summary: Do the pung + operationId: doPung + parameters: + - name: id + in: path + description: Id to pung + required: true + explode: true + schema: + type: integer + format: int64 + responses: + 200: + description: successful pung operation with all types + content: + application/json: + schema: + $ref: '#/components/schemas/StringsType' + plain/text: + schema: + type: string +components: + schemas: + DateType: + required: + - date + type: object + properties: + date: + type: string + format: date + DateTimeType: + required: + - dateTime + type: object + properties: + dateTime: + type: string + format: date-time + AllOfType: + allOf: + - $ref: '#/components/schemas/NumbersType' + - $ref: '#/components/schemas/StringsType' + - $ref: '#/components/schemas/MultipleOfType' + - $ref: '#/components/schemas/DatesType' + discriminator: + propertyName: type + mapping: + NumbersType: '#/components/schemas/NumbersType' + StringsType: '#/components/schemas/StringsType' + MultipleOfType: '#/components/schemas/MultipleOfType' + DatesType: '#/components/schemas/DatesType' + AnyOfType: + anyOf: + - $ref: '#/components/schemas/NumbersType' + - $ref: '#/components/schemas/StringsType' + - $ref: '#/components/schemas/MultipleOfType' + - $ref: '#/components/schemas/DatesType' + discriminator: + propertyName: type + mapping: + NumbersType: '#/components/schemas/NumbersType' + StringsType: '#/components/schemas/StringsType' + MultipleOfType: '#/components/schemas/MultipleOfType' + DatesType: '#/components/schemas/DatesType' + OneOfType: + oneOf: + - $ref: '#/components/schemas/NumbersType' + - $ref: '#/components/schemas/StringsType' + - $ref: '#/components/schemas/MultipleOfType' + - $ref: '#/components/schemas/DatesType' + discriminator: + propertyName: type + mapping: + NumbersType: '#/components/schemas/NumbersType' + StringsType: '#/components/schemas/StringsType' + MultipleOfType: '#/components/schemas/MultipleOfType' + DatesType: '#/components/schemas/DatesType' + MultipleOfType: + type: object + required: + - type + - manyPi + - even + properties: + type: + type: string + enum: [ MultiplesType ] + manyPi: + type: number + format: double + multipleOf: 3.14159 + minimum: 0 + maximum: 31459 + even: + type: integer + format: int32 + multipleOf: 2 + minimum: -2000 + maximum: 2000 + StringsType: + type: object + required: + - type + properties: + type: + type: string + enum: [ StringsType ] + smallString: + type: string + minLength: 0 + maxLength: 10 + mediumString: + type: string + minLength: 0 + maxLength: 256 + largeString: + type: string + minLength: 0 + maxLength: 1024 + nonEmptyString: + type: string + minLength: 256 + maxLength: 512 + NumbersType: + type: object + required: + - type + - integerInt32 + - integerInt64 + - numberFloat + - numberDouble + - positiveIntegerInt32 + - negativeIntegerInt64 + - positiveNumberFloat + - negativeNumberDouble + - betweenIntegerInt32 + - betweenIntegerInt64 + - betweenNumberFloat + - betweenNumberDouble + - betweenIntegerInt32Exclude + - betweenIntegerInt64Exclude + - betweenNumberFloatExclude + - betweenNumberDoubleExclude + properties: + type: + type: string + enum: [ NumbersType ] + integerInt32: + type: integer + format: int32 + integerInt64: + type: integer + format: int64 + numberFloat: + type: number + format: float + numberDouble: + type: number + format: double + positiveIntegerInt32: + type: integer + format: int32 + minimum: 0 + negativeIntegerInt64: + type: integer + format: int64 + maximum: 0 + positiveNumberFloat: + type: number + format: float + minimum: 0 + negativeNumberDouble: + type: number + format: double + maximum: 0 + betweenIntegerInt32: + type: integer + format: int32 + minimum: 2 + maximum: 8 + betweenIntegerInt64: + type: integer + format: int64 + minimum: 2 + maximum: 3 + betweenNumberFloat: + type: number + format: float + minimum: 2 + maximum: 3 + betweenNumberDouble: + type: number + format: double + minimum: 2 + maximum: 3 + betweenIntegerInt32Exclude: + type: integer + format: int32 + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + betweenIntegerInt64Exclude: + type: integer + format: int64 + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + betweenNumberFloatExclude: + type: number + format: float + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + betweenNumberDoubleExclude: + type: number + format: double + minimum: 2 + maximum: 4 + exclusiveMinimum: true + exclusiveMaximum: true + DatesType: + required: + - type + - date + - dateTime + type: object + properties: + type: + type: string + enum: [ DatesType ] + date: + type: string + format: date + dateTime: + type: string + format: date-time + PingReqType: + type: object + properties: + id: + type: integer + format: int64 + Detail1: + type: object + required: + - type + properties: + type: + type: string + enum: [ Detail1Type ] + allTypes: + $ref: '#/components/schemas/NumbersType' + Detail2: + type: object + required: + - type + properties: + type: + type: string + enum: [ Detail2Type ] + allString: + $ref: '#/components/schemas/StringsType' + allDates: + $ref: '#/components/schemas/DatesType' + PingRespType: + type: object + required: + - type + properties: + type: + type: string + enum: [ PingRespType ] + id: + type: integer + format: int64 + value: + type: string + other: + anyOf: + - $ref: '#/components/schemas/Detail1' + - $ref: '#/components/schemas/Detail2' + discriminator: + propertyName: type + mapping: + Detail1Type: '#/components/schemas/Detail1' + Detail2Type: '#/components/schemas/Detail2' + BooleanType: + type: object + required: + - isActive + - isVerified + properties: + isActive: + type: boolean + isVerified: + type: boolean + EnumType: + type: object + required: + - status + properties: + status: + type: string + enum: + - ACTIVE + - INACTIVE + - PENDING + NestedType: + type: object + properties: + id: + type: integer + format: int64 + details: + $ref: '#/components/schemas/Detail1' + SimpleArrayType: + type: object + properties: + stringItems: + type: array + items: + type: string + minLength: 2 + maxLength: 5 + minItems: 10 + maxItems: 20 + numberItems: + type: array + items: + type: integer + minItems: 10 + maxItems: 20 + booleanItems: + type: array + items: + type: boolean + dateItems: + type: array + items: + type: string + format: date + ComplexArrayType: + type: object + properties: + stringItems: + type: array + items: + $ref: '#/components/schemas/StringsType' + numberItems: + type: array + items: + $ref: '#/components/schemas/NumbersType' + ArrayOfArraysType: + type: object + properties: + matrix: + type: array + items: + type: array + items: + type: integer + NullableType: + type: object + properties: + nullableString: + type: string + nullable: true + DefaultValueType: + type: object + properties: + defaultValue: + type: string + default: "defaultValue" \ No newline at end of file diff --git a/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java b/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java index 1d4fafcf24..d65d697a9f 100644 --- a/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java +++ b/core/citrus-api/src/main/java/org/citrusframework/spi/ReferenceResolverAware.java @@ -16,6 +16,8 @@ package org.citrusframework.spi; +import jakarta.annotation.Nullable; + /** * @author Christoph Deppisch */ @@ -26,5 +28,5 @@ public interface ReferenceResolverAware { * Sets the reference resolver. * @param referenceResolver */ - void setReferenceResolver(ReferenceResolver referenceResolver); + void setReferenceResolver(@Nullable ReferenceResolver referenceResolver); } diff --git a/core/citrus-base/pom.xml b/core/citrus-base/pom.xml index 3dd7675b7e..8f4f8991bc 100644 --- a/core/citrus-base/pom.xml +++ b/core/citrus-base/pom.xml @@ -28,12 +28,16 @@ commons-codec commons-codec - jakarta.xml.bind jakarta.xml.bind-api provided + + com.github.mifmif + generex + 1.0.2 + diff --git a/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java b/core/citrus-base/src/main/java/org/citrusframework/functions/DefaultFunctionLibrary.java index 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: + *

    + *
  1. Decimal places: The number of decimal places in the generated random number (optional, default: 0). Note that definition of 0 results in an integer.
  2. + *
  3. Min value: The minimum value for the generated random number (optional, default: Double.MIN_VALUE).
  4. + *
  5. Max value: The maximum value for the generated random number (optional, default: Double.MAX_VALUE).
  6. + *
  7. Exclude min: Whether to exclude the minimum value (optional, default: false).
  8. + *
  9. Exclude max: Whether to exclude the maximum value (optional, default: false).
  10. + *
  11. Multiple of: The generated number will be a multiple of this value (optional).
  12. + *
+ *

+ * 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.14 4.6.0 1.1.27 + com.atlassian.oai 1.8.0 3.25.1 1.78.1 @@ -581,6 +582,18 @@ ${apicurio.data-models.version} + + com.atlassian.oai + swagger-request-validator-core + ${swagger-request-validator.version} + + + commons-logging + commons-logging + + + + org.eclipse.jetty @@ -1183,6 +1196,12 @@ mockito-junit-jupiter test + + uk.org.webcompere + system-stubs-core + 2.1.6 + test + diff --git a/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java b/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java index dcdb87870b..1a4c5d7107 100644 --- a/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java +++ b/runtime/citrus-testng/src/main/java/org/citrusframework/testng/spring/TestNGCitrusSpringSupport.java @@ -116,7 +116,8 @@ public void run(final IHookCallBack callBack, ITestResult testResult) { * @param methodTestLoaders * @param invocationCount */ - protected void run(ITestResult testResult, Method method, List methodTestLoaders, int invocationCount) { + protected void run(ITestResult testResult, Method method, List methodTestLoaders, + int invocationCount) { if (citrus == null) { citrus = Citrus.newInstance(new CitrusSpringContextProvider(applicationContext)); CitrusAnnotations.injectCitrusFramework(this, citrus); @@ -164,6 +165,9 @@ protected void run(ITestResult testResult, Method method, List metho @BeforeClass(alwaysRun = true) public final void before() { + // We need to consider the possibility, that one test has meanwhile modified the current citrus instance, + // as there can be plenty of tests running between @BeforeSuite and the execution of an actual subclass of + // this support. The citrus instance may even have a mocked context. if (citrus == null) { citrus = Citrus.newInstance(new CitrusSpringContextProvider(applicationContext)); CitrusAnnotations.injectCitrusFramework(this, citrus); @@ -206,7 +210,7 @@ public final void beforeSuite() { CitrusAnnotations.injectCitrusFramework(this, citrus); beforeSuite(citrus.getCitrusContext()); citrus.beforeSuite(Reporter.getCurrentTestResult().getTestContext().getSuite().getName(), - Reporter.getCurrentTestResult().getTestContext().getIncludedGroups()); + Reporter.getCurrentTestResult().getTestContext().getIncludedGroups()); } /** 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; - } }