diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f3cda1b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,21 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+
+## [1.1.0-beta-1] - 2026-07-02
+
+### Added
+- `readOnly`: when `true`, only generates GET and OPTIONS test cases.
+- `serverPattern`: selects the OpenAPI server whose URL matches the substring (e.g. `%dev%`); defaults to the first server if none match, or all servers if omitted.
+- `minimalEndpoints`: when `false` (default), adds a valid (200) and an invalid (400) test case per optional query parameter. When `true`, only generates the `testCaseNames`-based cases.
+- `microcksHeaders`: when `true`, adds an `X-Microcks-Response-Name` header with the matching response example name (or `default`).
+- `generateOneOfAnyOf`: when `true`, resolves `oneOf`/`anyOf` to their first candidate when generating example bodies (`allOf` is always merged).
+- `examples`: optional `{ successful, wrong }` object with custom `string`/`number`/`boolean`/`date`/`dateTime` values. `successful` feeds example bodies and valid query-param values; `wrong` feeds the invalid query-param values from `minimalEndpoints`. Unset fields keep the existing defaults.
+- `validateSchema`: when `true`, adds an automatic check (an assertion) to each main test case's request test step that verifies the response body matches the JSON Schema of the operation's first 2xx JSON response. Covers type, required, properties, items, enum, oneOf/anyOf, allOf, nullable, pattern, format (email/uuid/date/date-time), length/size/range bounds and additionalProperties.
+- `isInline`: when `false` (default), JSON request-body example values are generated as SoapUI Project Properties and referenced from the body via a `${#Project#...}` token instead of being embedded literally. When `true`, literal values are embedded directly in the body (previous behavior).
+- `schemaIsInline`: only relevant when `validateSchema` is `true`. When `false` (default), the JSON Schema used by that check is stored separately as a SoapUI Project Property and looked up automatically when the test runs, instead of being written out in full inside the check. When `true`, the full schema is written directly inside the check (previous/only behavior).
+- `schemaPrettyPrint`: only relevant when `validateSchema` is `true`. When `true` (default), the JSON Schema used by that check is pretty-printed (indented). When `false`, it is serialized compactly with no extra whitespace. Has no effect on JSON request-body example formatting.
diff --git a/pom.xml b/pom.xml
index 93fc651..fa29395 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
net.cloudappiopenapi2soapui
- 1.0.3
+ 1.1.0-beta-1${packaging.type}openapi2soapui
@@ -52,7 +52,8 @@
1.5.65.6.0
- 2.0.24
+ 2.1.39
+ 1.18.30
diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java
index 1822b67..e1bbe46 100644
--- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java
+++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java
@@ -30,4 +30,13 @@ private Constants() {
public static final String HEADERS_KEY = "headers";
public static final String AUTHENTICATION_PROFILES_KEY = "oAuth2Profiles";
public static final String TEST_CASE_NAMES_KEY = "testCaseNames";
+
+ public static final String VALID_HTTP_STATUS_CODES_ASSERTION = "Valid HTTP Status Codes";
+ public static final String SUCCESS_STATUS_CODE = "200";
+ public static final String WRONG_STATUS_CODE = "400";
+ public static final String SCRIPT_ASSERTION = "Script Assertion";
+ public static final String QUERY_PARAM_VARIANT_PREFIX = "queryString ";
+ public static final String QUERY_PARAM_VARIANT_WRONG_SUFFIX = " wrong";
+
+ public static final String MICROCKS_RESPONSE_NAME_HEADER = "X-Microcks-Response-Name";
}
diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java
index c263789..a0047a2 100644
--- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java
+++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java
@@ -34,8 +34,7 @@ public String newSoapUIProject(@Valid @RequestBody SoapUIProjectRequest newSoapU
if (openAPI != null && openAPI.getInfo() != null && openAPI.getInfo().getVersion() == null) {
throw new APIVersionNotFoundException("Version not found in OpenAPI");
}
- SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject.getApiName(), openAPI,
- newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames());
+ SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject, openAPI);
String projectContent = soapUIProject.getFileContent();
soapUIProject.deleteTemporaryFile();
return projectContent;
diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java
index 12e0e48..b0aca90 100644
--- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java
+++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java
@@ -13,6 +13,13 @@
import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.DEFAULT;
import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.JSON;
import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.SUCCESS_TEST_CASE;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.VALID_HTTP_STATUS_CODES_ASSERTION;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.SUCCESS_STATUS_CODE;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.WRONG_STATUS_CODE;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.SCRIPT_ASSERTION;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.QUERY_PARAM_VARIANT_PREFIX;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.QUERY_PARAM_VARIANT_WRONG_SUFFIX;
+import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.MICROCKS_RESPONSE_NAME_HEADER;
import java.io.File;
import java.io.IOException;
@@ -20,10 +27,14 @@
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.Date;
+import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.net.MalformedURLException;
@@ -57,7 +68,11 @@
import com.eviware.soapui.impl.wsdl.WsdlProject;
import com.eviware.soapui.impl.wsdl.WsdlTestSuite;
import com.eviware.soapui.impl.wsdl.testcase.WsdlTestCase;
+import com.eviware.soapui.impl.wsdl.teststeps.RestTestRequestStep;
+import com.eviware.soapui.impl.wsdl.teststeps.WsdlTestStep;
+import com.eviware.soapui.impl.wsdl.teststeps.assertions.basic.GroovyScriptAssertion;
import com.eviware.soapui.impl.wsdl.teststeps.registry.RestRequestStepFactory;
+import com.eviware.soapui.security.assertion.ValidHttpStatusCodesAssertion;
import com.eviware.soapui.support.SoapUIException;
import com.eviware.soapui.support.types.StringToStringMap;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -86,6 +101,9 @@
import lombok.Getter;
import org.apiaddicts.apitools.openapi2soapui.request.GrantType;
import org.apiaddicts.apitools.openapi2soapui.request.Header;
+import org.apiaddicts.apitools.openapi2soapui.request.ExampleValues;
+import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig;
+import org.apiaddicts.apitools.openapi2soapui.util.QueryParamExampleUtils;
import org.apiaddicts.apitools.openapi2soapui.util.RefResolver;
/**
@@ -126,7 +144,85 @@ public class SoapUIProject {
* Test case names from request body
*/
private Set testCaseNames;
-
+ /**
+ * When true, only GET and OPTIONS test cases are generated
+ */
+ private boolean readOnly;
+ /**
+ * When false, an extra valid/invalid test case pair is generated for each optional query parameter
+ */
+ private boolean minimalEndpoints;
+ /**
+ * When true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers
+ */
+ private boolean microcksHeaders;
+ /**
+ * When true, oneOf/anyOf schemas are resolved using their first candidate when generating example bodies.
+ * allOf schemas are always merged into a single object, regardless of this flag.
+ */
+ private boolean generateOneOfAnyOf;
+ /**
+ * When true, adds a Script Assertion to each main test-case request's test step that validates the response
+ * body against the JSON Schema of the operation's first 2xx JSON response
+ */
+ private boolean validateSchema;
+ /**
+ * Only relevant when validateSchema is true. When true, the response JSON Schema used by the
+ * validateSchema assertion is embedded literally in the Script Assertion text (previous/only
+ * behavior). When false (default), the schema is stored as a SoapUI Project Property and read
+ * at runtime from the script via a context.expand("${#Project#key}") call instead.
+ */
+ private boolean schemaIsInline;
+ /**
+ * Only relevant when validateSchema is true. When true (default), the JSON Schema embedded or
+ * referenced by the validateSchema assertion is pretty-printed (indented). When false, it is
+ * serialized compactly (no extra whitespace).
+ */
+ private boolean schemaPrettyPrint;
+ /**
+ * Custom example values from request body, used before falling back to internal defaults
+ */
+ private ExamplesConfig examples;
+ /**
+ * OpenAPI Operation for each generated Method, keyed by a stable path+httpMethod key (not by RestMethod
+ * object identity, which is not guaranteed stable across SoapUI accessor calls), used to build optional
+ * query parameter variant requests
+ */
+ private Map operationByMethodKey = new HashMap<>();
+ /**
+ * When true, request-body example values are embedded literally in the JSON body.
+ * When false (default), each generated scalar body value is stored as a SoapUI Project
+ * Property and referenced from the body via a "${#Project#key}" expansion token.
+ */
+ private boolean isInline;
+ /**
+ * Incremented once per JSON request body generated (see getRequestExample), used as a
+ * globally-unique prefix for Project Property keys so that fields with the same name/path
+ * across different operations never collide.
+ */
+ private int bodyPropertyCounter = 0;
+ /**
+ * Incremented once per validateSchema assertion built, used as a globally-unique suffix for the
+ * "schema" Project Property key when schemaIsInline is false (mirrors bodyPropertyCounter).
+ */
+ private int schemaPropertyCounter = 0;
+ /**
+ * For the JSON request body currently being generated: maps each "${#Project#key}" token
+ * produced to whether its underlying value is string-typed (true) or not (false: number/
+ * boolean/date). Reset at the start of every getRequestExample call. Used after
+ * mapObjectToJsonString to strip the JSON quotes the org.json serializer necessarily puts
+ * around the token (a Java String) when the real value is not itself a JSON string.
+ */
+ private Map currentBodyTokenTypes = new LinkedHashMap<>();
+
+ /**
+ * Backward-compatible overload; schemaPrettyPrint defaults to true.
+ */
+ public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List headers, Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, Boolean validateSchema, Boolean schemaIsInline, Boolean isInline, ExamplesConfig examples) throws IOException, XmlException, SoapUIException {
+ this(apiName, openAPI, oAuth2Profiles, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints,
+ microcksHeaders, generateOneOfAnyOf, validateSchema, schemaIsInline, isInline, true, examples);
+ }
+
/**
* SoapUIProject constructor
* Set default test case names if testCaseNames is null or empty
@@ -143,23 +239,42 @@ public class SoapUIProject {
* @param oAuth2Profiles authentication profiles from request body
* @param headers from request body
* @param testCaseNames from request body
+ * @param readOnly if true, only GET and OPTIONS test cases are generated
+ * @param minimalEndpoints if false, an extra valid/invalid test case pair is generated for each optional query parameter
+ * @param microcksHeaders if true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers
+ * @param generateOneOfAnyOf if true, oneOf/anyOf schemas are resolved using their first candidate when generating example bodies
+ * @param validateSchema if true, adds a Script Assertion to each main test-case request's test step that validates the response body against the JSON Schema of the operation's first 2xx JSON response
+ * @param schemaIsInline only relevant when validateSchema is true; if false (default), the response JSON Schema is stored as a SoapUI Project Property and read via a context.expand("${#Project#key}") call instead of being embedded literally
+ * @param isInline if false (default), JSON request-body example values are stored as SoapUI Project Properties and referenced via a "${#Project#key}" token instead of being embedded literally
+ * @param schemaPrettyPrint if true (default), the JSON Schema used by the validateSchema assertion is pretty-printed (indented); if false, it is serialized compactly with no extra whitespace
+ * @param examples custom example values from request body, used before falling back to internal defaults
* @throws IOException
* @throws XmlException
* @throws SoapUIException
*/
- public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List headers, Set testCaseNames) throws IOException, XmlException, SoapUIException {
+ public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List headers, Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, Boolean validateSchema, Boolean schemaIsInline, Boolean isInline, Boolean schemaPrettyPrint, ExamplesConfig examples) throws IOException, XmlException, SoapUIException {
this.apiName = apiName;
this.openAPI = openAPI;
this.headers = headers;
-
+ this.examples = examples;
+
this.apiVersion = openAPI.getInfo().getVersion();
-
+
if (testCaseNames == null || testCaseNames.isEmpty()) {
this.testCaseNames = new HashSet<>(Arrays.asList(SUCCESS_TEST_CASE));
} else {
this.testCaseNames = testCaseNames;
}
-
+
+ this.readOnly = Boolean.TRUE.equals(readOnly);
+ this.minimalEndpoints = Boolean.TRUE.equals(minimalEndpoints);
+ this.microcksHeaders = Boolean.TRUE.equals(microcksHeaders);
+ this.generateOneOfAnyOf = Boolean.TRUE.equals(generateOneOfAnyOf);
+ this.validateSchema = Boolean.TRUE.equals(validateSchema);
+ this.schemaIsInline = Boolean.TRUE.equals(schemaIsInline);
+ this.isInline = Boolean.TRUE.equals(isInline);
+ this.schemaPrettyPrint = !Boolean.FALSE.equals(schemaPrettyPrint);
+
createTempFile();
project = new WsdlProject();
@@ -172,7 +287,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List servers) {
- for (Server server : servers) {
+ private void setRestServiceEndpoints(List servers, String serverPattern) {
+ if (servers == null || servers.isEmpty()) return;
+ List filtered = servers;
+ if (serverPattern != null && !serverPattern.isBlank()) {
+ String cleanPattern = serverPattern.replace("%", "");
+ Optional match = servers.stream()
+ .filter(s -> s.getUrl().contains(cleanPattern))
+ .findFirst();
+ filtered = match.isPresent()
+ ? Collections.singletonList(match.get())
+ : Collections.singletonList(servers.get(0));
+ }
+ for (Server server : filtered) {
String serverUrl = server.getUrl();
try {
URL url = new URL(serverUrl);
@@ -404,6 +530,7 @@ private void setMethodsRequests(String pathName, PathItem pathItem) {
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
RestMethod restMethod = restResource.getRestMethodByName((operation.getOperationId() != null) ? operation.getOperationId() : httpMethod.name());
if (restMethod == null) return;
+ operationByMethodKey.put(methodKey(restResource.getPath(), httpMethod.name()), operation);
RestRequest restRequest = restMethod.addNewRequest(DEFAULT_REQUEST_NAME);
RestRequestConfig restRequestConfig = restRequest.getConfig();
@@ -424,7 +551,7 @@ private void setMethodsRequests(String pathName, PathItem pathItem) {
}
}
- setRequestHeaders(restRequest);
+ setRequestHeaders(restRequest, operation);
});
}
}
@@ -432,16 +559,64 @@ private void setMethodsRequests(String pathName, PathItem pathItem) {
/**
* Set Request Headers
* Iterate headers received in request body and set to Request
+ * If microcksHeaders is true, additionally set the X-Microcks-Response-Name header
* @param restRequest instance of Method Request
+ * @param operation instance of OpenAPI Operation, used to resolve the Microcks response example name
*/
- private void setRequestHeaders(RestRequest restRequest) {
+ private void setRequestHeaders(RestRequest restRequest, Operation operation) {
+ StringToStringMap requestHeaders = new StringToStringMap();
if (headers != null && !headers.isEmpty()) {
- StringToStringMap requestHeaders = new StringToStringMap();
headers.forEach(header -> requestHeaders.put(header.getKey(), header.getValue()));
+ }
+ if (microcksHeaders && !requestHeaders.containsKey(MICROCKS_RESPONSE_NAME_HEADER)) {
+ String exampleName = getMicrocksExampleName(operation);
+ requestHeaders.put(MICROCKS_RESPONSE_NAME_HEADER, exampleName != null ? exampleName : DEFAULT);
+ }
+ if (!requestHeaders.isEmpty()) {
restRequest.setRequestHeaders(requestHeaders);
}
}
+ /**
+ * Get Microcks Example Name
+ * Look for the first named example defined on the operation's responses, checking 2xx responses in
+ * spec declaration order first, then the "default" response, and every media type within each response
+ * @param operation instance of OpenAPI Operation
+ * @return example name, or null if the operation has no response example
+ */
+ private String getMicrocksExampleName(Operation operation) {
+ if (operation.getResponses() == null) return null;
+ List candidateResponses = new ArrayList<>();
+ operation.getResponses().forEach((code, response) -> {
+ if (code.startsWith("2")) candidateResponses.add(response);
+ });
+ if (operation.getResponses().getDefault() != null) {
+ candidateResponses.add(operation.getResponses().getDefault());
+ }
+
+ for (ApiResponse response : candidateResponses) {
+ String exampleName = getFirstExampleName(response);
+ if (exampleName != null) return exampleName;
+ }
+ return null;
+ }
+
+ /**
+ * Get First Example Name
+ * Iterate every media type of the Response content and return the key of the first non-empty examples map
+ * @param response instance of OpenAPI Response
+ * @return example name, or null if no media type of this response has named examples
+ */
+ private String getFirstExampleName(ApiResponse response) {
+ if (response == null || response.getContent() == null || response.getContent().isEmpty()) return null;
+ for (MediaType mediaType : response.getContent().values()) {
+ if (mediaType.getExamples() != null && !mediaType.getExamples().isEmpty()) {
+ return mediaType.getExamples().entrySet().iterator().next().getKey();
+ }
+ }
+ return null;
+ }
+
/**
* Set Request Content
* Get OpenAPI Request Body example and set as Request Content
@@ -456,6 +631,7 @@ private void setRequestContent(RestRequest restRequest, Content content) {
Object example = getRequestExample(mediaTypeObject, refResolver);
if (example != null) {
String exampleStr = mapObjectToJsonString(example);
+ exampleStr = stripQuotesAroundNonStringTokens(exampleStr);
if (exampleStr != null) {
restRequest.setRequestContent(exampleStr);
}
@@ -475,8 +651,10 @@ private void setRequestContent(RestRequest restRequest, Content content) {
*/
@SuppressWarnings("rawtypes")
private Object getRequestExample(MediaType mediaType, RefResolver refResolver) {
+ bodyPropertyCounter++;
+ currentBodyTokenTypes = new LinkedHashMap<>();
Object example;
- Schema> schema = refResolver.resolveSchema(mediaType.getSchema());
+ Schema> schema = resolveComposedSchema(refResolver.resolveSchema(mediaType.getSchema()), refResolver);
if (mediaType.getExample() != null) {
example = mediaType.getExample();
} else if (mediaType.getExamples() != null && !mediaType.getExamples().isEmpty()) {
@@ -486,7 +664,7 @@ private Object getRequestExample(MediaType mediaType, RefResolver refResolver) {
}
if (example == null) {
Map properties = schema.getProperties();
- example = iterateProperties(properties, refResolver);
+ example = iterateProperties(properties, refResolver, "");
}
return example;
}
@@ -495,16 +673,18 @@ private Object getRequestExample(MediaType mediaType, RefResolver refResolver) {
* Iterate all properties of schema an set an example, if schema is $ref, $ref is resolved
* @param properties map of properties (property name, property schema)
* @param refResolver to help resolve schemas $ref
- * @return json object with example of its properties
+ * @param path underscore-joined property path built so far, used to key Project Properties when isInline is false
+ * @return json object with example of its properties
*/
@SuppressWarnings("rawtypes")
- private JSONObject iterateProperties(Map properties, RefResolver refResolver) {
+ private JSONObject iterateProperties(Map properties, RefResolver refResolver, String path) {
JSONObject json = new JSONObject();
if (properties != null && !properties.isEmpty()) {
properties.forEach((propertyName, property) -> {
property = refResolver.resolveSchema(property);
+ String childPath = path.isEmpty() ? propertyName : path + "_" + propertyName;
try {
- Object example = getPropertyExample(property, refResolver);
+ Object example = getPropertyExample(property, refResolver, childPath);
json.put(propertyName, example);
} catch (JSONException e) {
log.warn("Error iterateProperties", e);
@@ -513,69 +693,186 @@ private JSONObject iterateProperties(Map properties, RefResolver
}
return json;
}
-
+
/**
* Get property example
* Validate if Property Schema has example, if so, return example value
* If not, return a generic value according to data type
* @param property
* @param refResolver
+ * @param path underscore-joined property path, used to key Project Properties when isInline is false
* @return
* @throws JSONException
*/
@SuppressWarnings("rawtypes")
- private Object getPropertyExample(Schema property, RefResolver refResolver) throws JSONException {
+ private Object getPropertyExample(Schema property, RefResolver refResolver, String path) throws JSONException {
Object example = property.getExample();
-
- if (example == null) {
- if (property instanceof ObjectSchema) {
- example = iterateProperties(((ObjectSchema) property).getProperties(), refResolver);
- } else if (property instanceof ArraySchema) {
- JSONArray jsonArray = new JSONArray();
- Schema> items = refResolver.resolveSchema(((ArraySchema) property).getItems());
- jsonArray.put(getPropertyExample(items, refResolver));
- example = jsonArray;
- } else if (property instanceof IntegerSchema) {
- example = 0;
- } else if (property instanceof NumberSchema) {
- example = 0;
- } else if (property instanceof BooleanSchema) {
- example = true;
- } else if (property instanceof DateSchema) {
- example = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
- } else if (property instanceof StringSchema) {
- StringSchema stringProperty = (StringSchema) property;
- List enums = stringProperty.getEnum();
- if (enums != null && !enums.isEmpty()) {
- example = enums.get(0);
- } else {
- example = "";
- }
- } else {
- example = "";
+ if (example != null) {
+ return registerBodyValue(path, example);
+ }
+ return getExampleForResolvedType(resolveComposedSchema(property, refResolver), refResolver, path);
+ }
+
+ /**
+ * Get example for a resolved (non-composed) schema, dispatching by concrete schema type
+ * @param property resolved Schema
+ * @param refResolver instance of RefResolver
+ * @param path underscore-joined property path, used to key Project Properties when isInline is false
+ * @return example value for the schema's type
+ * @throws JSONException
+ */
+ @SuppressWarnings("rawtypes")
+ private Object getExampleForResolvedType(Schema property, RefResolver refResolver, String path) throws JSONException {
+ if (property instanceof ObjectSchema) {
+ return iterateProperties(((ObjectSchema) property).getProperties(), refResolver, path);
+ } else if (property instanceof ArraySchema) {
+ JSONArray jsonArray = new JSONArray();
+ Schema> items = refResolver.resolveSchema(((ArraySchema) property).getItems());
+ jsonArray.put(getPropertyExample(items, refResolver, path + "_item"));
+ return jsonArray;
+ } else if (property instanceof IntegerSchema || property instanceof NumberSchema) {
+ return registerBodyValue(path, getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO));
+ } else if (property instanceof BooleanSchema) {
+ return registerBodyValue(path, getConfiguredExample(false, ExampleValues::getBooleanValue, true));
+ } else if (property instanceof DateSchema) {
+ return registerBodyValue(path, getConfiguredExample(false, ExampleValues::getDate, new SimpleDateFormat("yyyy-MM-dd").format(new Date())));
+ } else if (property instanceof StringSchema) {
+ return registerBodyValue(path, getStringExample((StringSchema) property));
+ }
+ return registerBodyValue(path, "");
+ }
+
+ /**
+ * Get example for a String schema, honoring enum values and the date-time format before falling back
+ * to a configured/default string
+ * @param stringProperty String Schema
+ * @return example value
+ */
+ private Object getStringExample(StringSchema stringProperty) {
+ List enums = stringProperty.getEnum();
+ if (enums != null && !enums.isEmpty()) {
+ return enums.get(0);
+ } else if ("date-time".equalsIgnoreCase(stringProperty.getFormat())) {
+ return getConfiguredExample(false, ExampleValues::getDateTime, "");
+ }
+ return getConfiguredExample(false, ExampleValues::getString, "");
+ }
+
+ /**
+ * Look up a custom example value from the request body's "examples" configuration, falling back to
+ * defaultValue if "examples" was not provided, or the requested space/field was not configured
+ * @param wrong true to look up examples.wrong, false to look up examples.successful
+ * @param getter accessor for the desired field on ExampleValues
+ * @param defaultValue value to use if not configured
+ * @return the configured value, or defaultValue
+ */
+ private T getConfiguredExample(boolean wrong, java.util.function.Function getter, T defaultValue) {
+ if (examples == null) {
+ return defaultValue;
+ }
+ ExampleValues values = wrong ? examples.getWrong() : examples.getSuccessful();
+ if (values == null) {
+ return defaultValue;
+ }
+ T configured = getter.apply(values);
+ return configured != null ? configured : defaultValue;
+ }
+
+ /**
+ * Resolve oneOf/anyOf/allOf composition on a schema
+ * allOf is always merged into a single object schema (properties/required of every member), regardless of generateOneOfAnyOf
+ * When generateOneOfAnyOf is true, oneOf/anyOf are resolved to their first candidate schema
+ * Loops to also resolve composition nested inside a merged/chosen candidate, bounded to avoid runaway recursion
+ * @param schema to resolve
+ * @param refResolver instance of RefResolver
+ * @return resolved schema, or the original schema unchanged if it has no applicable composition
+ */
+ @SuppressWarnings("rawtypes")
+ private Schema resolveComposedSchema(Schema schema, RefResolver refResolver) {
+ final int MAX_ITERATIONS = 10;
+ for (int i = 0; i < MAX_ITERATIONS; i++) {
+ Schema resolved = resolveComposedSchemaOnce(schema, refResolver);
+ if (resolved == schema) {
+ return resolved;
}
+ schema = resolved;
}
-
- return example;
+ return schema;
}
/**
- * Convert Object or JSONObject to JSON String
+ * Resolve a single level of oneOf/anyOf/allOf composition on a schema
+ * @param schema to resolve
+ * @param refResolver instance of RefResolver
+ * @return resolved schema, or the original schema unchanged if it has no applicable composition
+ */
+ @SuppressWarnings("rawtypes")
+ private Schema resolveComposedSchemaOnce(Schema schema, RefResolver refResolver) {
+ List allOf = schema.getAllOf();
+ if (allOf != null && !allOf.isEmpty()) {
+ return mergeAllOf(allOf, refResolver);
+ } else if (generateOneOfAnyOf && schema.getOneOf() != null && !schema.getOneOf().isEmpty()) {
+ return refResolver.resolveSchema((Schema) schema.getOneOf().get(0));
+ } else if (generateOneOfAnyOf && schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) {
+ return refResolver.resolveSchema((Schema) schema.getAnyOf().get(0));
+ }
+ return schema;
+ }
+
+ /**
+ * Merge every allOf member into a single object schema (properties/required of every member)
+ * @param allOf list of member schemas to merge
+ * @param refResolver instance of RefResolver
+ * @return merged object schema
+ */
+ @SuppressWarnings("rawtypes")
+ private ObjectSchema mergeAllOf(List allOf, RefResolver refResolver) {
+ ObjectSchema merged = new ObjectSchema();
+ Map mergedProperties = new HashMap<>();
+ List mergedRequired = new ArrayList<>();
+ for (Schema member : allOf) {
+ Schema resolvedMember = refResolver.resolveSchema(member);
+ if (resolvedMember.getProperties() != null) {
+ mergedProperties.putAll(resolvedMember.getProperties());
+ }
+ if (resolvedMember.getRequired() != null) {
+ mergedRequired.addAll(resolvedMember.getRequired());
+ }
+ }
+ merged.setProperties(mergedProperties);
+ merged.setRequired(mergedRequired);
+ return merged;
+ }
+
+ /**
+ * Convert Object or JSONObject to JSON String, always pretty-printed (indented)
* @param object to convert
* @return json string
*/
private String mapObjectToJsonString(Object object) {
+ return mapObjectToJsonString(object, true);
+ }
+
+ /**
+ * Convert Object or JSONObject to JSON String
+ * @param object to convert
+ * @param prettyPrint if true, the JSON is indented; if false, it is serialized compactly with no extra whitespace
+ * @return json string
+ */
+ private String mapObjectToJsonString(Object object, boolean prettyPrint) {
String jsonString = null;
if (object instanceof JSONObject) {
try {
- jsonString = ((JSONObject) object).toString(2);
+ jsonString = prettyPrint ? ((JSONObject) object).toString(2) : ((JSONObject) object).toString();
} catch (JSONException e) {
log.debug("Error mapObjectToJsonString", e);
}
} else {
ObjectMapper mapper = new ObjectMapper();
try {
- jsonString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(object).replaceAll("\\r", "");
+ jsonString = prettyPrint
+ ? mapper.writerWithDefaultPrettyPrinter().writeValueAsString(object).replaceAll("\\r", "")
+ : mapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
log.debug("Error mapObjectToJsonString", e);
}
@@ -583,6 +880,47 @@ private String mapObjectToJsonString(Object object) {
return jsonString;
}
+ /**
+ * If isInline is false and value is a tokenizable scalar (String/Number/Boolean), stores it as a
+ * SoapUI Project Property keyed by a globally-unique, path-based name and returns the
+ * "${#Project#key}" reference token to place in the JSON body instead of the literal value.
+ * Otherwise (isInline true, value is null, or value is a non-scalar Object such as a whole-object
+ * schema example) returns value unchanged.
+ * @param path underscore-joined property path built by iterateProperties/getExampleForResolvedType
+ * @param value the computed example value for that path
+ * @return the literal value (isInline / non-scalar) or a "${#Project#key}" token (otherwise)
+ */
+ private Object registerBodyValue(String path, Object value) {
+ if (isInline || value == null || !(value instanceof String || value instanceof Number || value instanceof Boolean)) {
+ return value;
+ }
+ String key = "body" + bodyPropertyCounter + "_" + path.replaceAll("[^A-Za-z0-9_]", "_");
+ project.setPropertyValue(key, String.valueOf(value));
+ currentBodyTokenTypes.put(key, value instanceof String);
+ return "${#Project#" + key + "}";
+ }
+
+ /**
+ * Strips the JSON quotes the org.json serializer necessarily wrote around each non-string token
+ * (org.json only knows how to serialize the token as a Java String), so that after SoapUI
+ * resolves the property at runtime the field keeps its correct JSON type (number/boolean)
+ * instead of becoming a quoted string. No-op when isInline is true (currentBodyTokenTypes is empty).
+ * @param json serialized request body JSON, possibly containing "${#Project#key}" tokens
+ * @return json with quotes stripped around every non-string token
+ */
+ private String stripQuotesAroundNonStringTokens(String json) {
+ if (json == null || currentBodyTokenTypes.isEmpty()) {
+ return json;
+ }
+ for (Map.Entry entry : currentBodyTokenTypes.entrySet()) {
+ if (Boolean.FALSE.equals(entry.getValue())) {
+ String token = "${#Project#" + entry.getKey() + "}";
+ json = json.replace("\"" + token + "\"", token);
+ }
+ }
+ return json;
+ }
+
/**
* Set Media Type to Request
* If OpenAPI Operation has success response, get Medi Type of success response and set as Reques Media Type
@@ -689,24 +1027,462 @@ private void setAuthProfile(org.apiaddicts.apitools.openapi2soapui.request.OAuth
*/
private void setTestCases() {
List resources = restService.getAllResources();
- if (resources != null && !resources.isEmpty()) {
- resources.forEach(restResource -> {
- List methods = restResource.getRestMethodList();
- if (methods != null && !methods.isEmpty()) {
- methods.forEach(restMethod -> {
- String method = restMethod.getMethod().name();
- String testSuiteName = restResource.getPath() + "_" + method + "_" + SUITE_SUFFIX;
- WsdlTestSuite testSuite = project.addNewTestSuite(testSuiteName);
- for (String testCaseNameItem : testCaseNames) {
- String testCaseName = testCaseNameItem + "_" + CASE_SUFFIX;
- WsdlTestCase testCase = testSuite.addNewTestCase(testCaseName);
- TestStepConfig ejecutionTestStepConfig = RestRequestStepFactory.createConfig(restMethod.getRequestByName(DEFAULT_REQUEST_NAME), EJECUTION_TEST_STEP + "_" + STEP_SUFFIX);
- testCase.addTestStep(ejecutionTestStepConfig);
- }
- });
+ if (resources == null || resources.isEmpty()) return;
+ resources.forEach(restResource -> {
+ List methods = restResource.getRestMethodList();
+ if (methods != null && !methods.isEmpty()) {
+ methods.forEach(restMethod -> addTestSuiteForMethod(restResource, restMethod));
+ }
+ });
+ }
+
+ /**
+ * Add Test Suite for a single Method
+ * Skipped for non read-only Methods when readOnly is true
+ * Adds a Test Case per configured test case name, plus optional query parameter variant Test Cases
+ * When validateSchema is true, also attaches a Script Assertion validating the response body against the
+ * operation's JSON Schema to each main test step
+ * @param restResource instance of Resource owning the Method
+ * @param restMethod instance of Method to generate the Test Suite for
+ */
+ private void addTestSuiteForMethod(RestResource restResource, RestMethod restMethod) {
+ String method = restMethod.getMethod().name();
+ if (readOnly && !"GET".equals(method) && !"OPTIONS".equals(method)) return;
+ String testSuiteName = restResource.getPath() + "_" + method + "_" + SUITE_SUFFIX;
+ WsdlTestSuite testSuite = project.addNewTestSuite(testSuiteName);
+ Operation operation = operationByMethodKey.get(methodKey(restResource.getPath(), method));
+ for (String testCaseNameItem : testCaseNames) {
+ String testCaseName = testCaseNameItem + "_" + CASE_SUFFIX;
+ WsdlTestCase testCase = testSuite.addNewTestCase(testCaseName);
+ TestStepConfig ejecutionTestStepConfig = RestRequestStepFactory.createConfig(restMethod.getRequestByName(DEFAULT_REQUEST_NAME), EJECUTION_TEST_STEP + "_" + STEP_SUFFIX);
+ WsdlTestStep testStep = testCase.addTestStep(ejecutionTestStepConfig);
+ if (validateSchema) {
+ addSchemaValidationAssertion(testStep, operation);
+ }
+ }
+ if (!minimalEndpoints) {
+ addQueryParamVariantTestCases(restResource, restMethod, testSuite);
+ }
+ }
+
+ /**
+ * Add Schema Validation Assertion
+ * Looks up the JSON Schema of the operation's first 2xx JSON response and, if found, attaches a Script
+ * Assertion to the given test step that parses the response body and validates it against that schema
+ * Skipped (with a warning) when the operation has no 2xx response with a JSON body to validate against
+ * @param testStep instance of Test Step to attach the assertion to
+ * @param operation instance of OpenAPI Operation the test step was built from, or null if unknown
+ */
+ private void addSchemaValidationAssertion(WsdlTestStep testStep, Operation operation) {
+ if (operation == null) return;
+ RefResolver refResolver = new RefResolver(openAPI);
+ Schema> responseSchema = getSuccessJsonResponseSchema(operation, refResolver);
+ if (responseSchema == null) {
+ log.warn("Test step {} has no 2xx JSON response schema to validate; validateSchema assertion skipped", testStep.getName());
+ return;
+ }
+ String script = buildSchemaValidationScript(buildJsonSchemaDefinition(responseSchema, refResolver, new HashSet<>()));
+ if (script == null) return;
+ GroovyScriptAssertion assertion = (GroovyScriptAssertion)
+ ((RestTestRequestStep) testStep).addAssertion(SCRIPT_ASSERTION);
+ assertion.setScriptText(script);
+ }
+
+ /**
+ * Get Success Json Response Schema
+ * Looks for the first 2xx response (in spec declaration order) that declares a JSON media type, and returns
+ * its (ref-resolved) Schema
+ * @param operation instance of OpenAPI Operation
+ * @param refResolver instance of RefResolver
+ * @return resolved Schema, or null if no 2xx response declares a JSON body
+ */
+ @SuppressWarnings("rawtypes")
+ private Schema getSuccessJsonResponseSchema(Operation operation, RefResolver refResolver) {
+ if (operation.getResponses() == null) return null;
+ for (Map.Entry entry : operation.getResponses().entrySet()) {
+ if (!entry.getKey().startsWith("2")) continue;
+ Content content = entry.getValue().getContent();
+ if (content == null) continue;
+ for (Map.Entry mediaTypeEntry : content.entrySet()) {
+ Schema schema = mediaTypeEntry.getValue().getSchema();
+ if (mediaTypeEntry.getKey().toLowerCase().contains(JSON) && schema != null) {
+ return refResolver.resolveSchema(schema);
}
- });
+ }
}
+ return null;
+ }
+
+ /**
+ * Build Json Schema Definition
+ * Converts an OpenAPI Schema tree into a plain JSON-Schema-shaped Map (type/properties/required/items/enum/
+ * nullable, plus allOf merged and oneOf/anyOf represented as an "anyOf" list)
+ * Independent from generateOneOfAnyOf/example-generation logic, since this builds a real schema definition
+ * for validation rather than a single example value
+ * $ref is resolved directly here (not via RefResolver): RefResolver only resolves a given $ref once for its
+ * whole lifetime (to protect its example-generation walk from infinite recursion on cyclic schemas), which
+ * would silently under-resolve a schema that is legitimately referenced more than once in the same response
+ * (e.g. the same Address schema used for both billingAddress and shippingAddress). refsInPath instead tracks
+ * only the refs currently being expanded along the current branch, so repeated-but-not-cyclic refs are fully
+ * resolved every time, while a true cycle (a schema that references itself, directly or indirectly) is still
+ * safely bounded to an open/permissive object instead of overflowing the stack
+ * @param schema to convert
+ * @param refResolver instance of RefResolver, still used to resolve allOf members via the shared mergeAllOf helper
+ * @param refsInPath $ref names currently being expanded along this branch, to detect cycles
+ * @return Map representing the equivalent JSON Schema definition, or null if schema is null
+ */
+ @SuppressWarnings("rawtypes")
+ private Object buildJsonSchemaDefinition(Schema schema, RefResolver refResolver, Set refsInPath) {
+ if (schema == null) return null;
+ String ref = schema.get$ref();
+ if (ref != null) {
+ if (refsInPath.contains(ref)) {
+ return new LinkedHashMap<>();
+ }
+ Schema target = resolveComponentSchema(ref);
+ if (target == null) return new LinkedHashMap<>();
+ Set nextRefsInPath = new HashSet<>(refsInPath);
+ nextRefsInPath.add(ref);
+ return buildJsonSchemaDefinition(target, refResolver, nextRefsInPath);
+ }
+ boolean nullable = Boolean.TRUE.equals(schema.getNullable());
+ if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) {
+ return applyNullable(buildJsonSchemaDefinition(mergeAllOf(schema.getAllOf(), refResolver), refResolver, refsInPath), nullable);
+ }
+ List alternatives = (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) ? schema.getOneOf() : schema.getAnyOf();
+ if (alternatives != null && !alternatives.isEmpty()) {
+ Map definition = new LinkedHashMap<>();
+ List