From ad32807a4f19711422feb20128cb012e1c7978c0 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Tue, 30 Jun 2026 13:53:08 -0500 Subject: [PATCH 01/11] feat: add readOnly parameter to filter write operations from test cases --- CHANGELOG.md | 12 ++++++++++++ .../controller/SoapUIProjectController.java | 5 +++-- .../openapi2soapui/model/SoapUIProject.java | 14 +++++++++++--- .../request/SoapUIProjectRequest.java | 3 +++ .../service/SoapUIProjectService.java | 2 +- .../service/SoapUIProjectServiceImpl.java | 4 ++-- src/main/resources/static/api.yaml | 9 ++++++++- 7 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..109cdba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# 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] - 2026-06-30 + +### Added +- Added `readOnly` parameter: when set to `true`, only GET and OPTIONS test cases are generated; POST, PUT, PATCH and DELETE operations are excluded. 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..89af74a 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,9 @@ 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.getApiName(), openAPI, + newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames(), + newSoapUIProject.getReadOnly()); 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..ea15814 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -126,6 +126,10 @@ 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; /** * SoapUIProject constructor @@ -143,22 +147,25 @@ 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 * @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) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; - + 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); createTempFile(); @@ -695,6 +702,7 @@ private void setTestCases() { if (methods != null && !methods.isEmpty()) { methods.forEach(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); for (String testCaseNameItem : testCaseNames) { diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index fd28d8d..1104ccb 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -37,4 +37,7 @@ public class SoapUIProjectRequest { @Valid @JsonProperty("headers") private List
headers; + + @JsonProperty("readOnly") + private Boolean readOnly; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index cd1593a..fff265c 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -12,5 +12,5 @@ import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 8ae2397..49971d6 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -18,8 +18,8 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames); + Set testCaseNames, Boolean readOnly) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index 4f6d995..08d19d8 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -56,7 +56,9 @@ paths: Additionally, it is possible to add custom headers to each of the requests, defining a list of key-value elements with the name "headers" as part of the request body, where key indicates the name of the header and value the value of the header. - The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. + Additionally, when the **readOnly** flag is set to true, only GET and OPTIONS test cases are generated; POST, PUT, PATCH and DELETE operations are excluded. + + The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. requestBody: description: | Information necessary for the generation of the SoapUI Project. @@ -67,6 +69,7 @@ paths: - openApiSpec: Base-64 encoded OpenAPI Spec - testCaseNames: List with the names of the test cases - headers: List of custom headers + - readOnly: Boolean flag to generate only read operations (GET/OPTIONS) content: aplication/json: schema: @@ -453,6 +456,10 @@ components: example: Success headers: $ref: '#/components/schemas/Headers' + readOnly: + type: boolean + description: If true, only GET and OPTIONS test cases are generated. POST, PUT, PATCH and DELETE are excluded. + example: false Headers: type: array description: List of optionals headers. This apply in all resources From 89386c866566c93beee5a87dcd542e075e8e0a0c Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Tue, 30 Jun 2026 17:19:24 -0500 Subject: [PATCH 02/11] feat: add serverPattern parameter to filter server endpoint --- CHANGELOG.md | 5 +++++ .../controller/SoapUIProjectController.java | 2 +- .../openapi2soapui/model/SoapUIProject.java | 21 +++++++++++++++---- .../request/SoapUIProjectRequest.java | 3 +++ .../service/SoapUIProjectService.java | 2 +- .../service/SoapUIProjectServiceImpl.java | 4 ++-- src/main/resources/static/api.yaml | 4 ++++ 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 109cdba..6c090af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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.2.0] - 2026-06-30 + +### Added +- Added `serverPattern` parameter: optional string (e.g. `%dev%`) that selects the OpenAPI server whose URL contains the given substring (after stripping `%`). If no server matches, the first server in the list is used as fallback. When omitted, all servers are added as endpoints (existing behavior preserved). + ## [1.1.0] - 2026-06-30 ### Added 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 89af74a..7dafa27 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java @@ -36,7 +36,7 @@ public String newSoapUIProject(@Valid @RequestBody SoapUIProjectRequest newSoapU } SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject.getApiName(), openAPI, newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames(), - newSoapUIProject.getReadOnly()); + newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern()); 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 ea15814..0d2be9a 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -20,10 +20,12 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.HashSet; 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; @@ -152,7 +154,7 @@ public class SoapUIProject { * @throws XmlException * @throws SoapUIException */ - public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly) throws IOException, XmlException, SoapUIException { + public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly, String serverPattern) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -179,7 +181,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); diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index 1104ccb..c16687e 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -40,4 +40,7 @@ public class SoapUIProjectRequest { @JsonProperty("readOnly") private Boolean readOnly; + + @JsonProperty("serverPattern") + private String serverPattern; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index fff265c..cd856e8 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -12,5 +12,5 @@ import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 49971d6..7e3d46c 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -18,8 +18,8 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames, Boolean readOnly) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly); + Set testCaseNames, Boolean readOnly, String serverPattern) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index 08d19d8..a978d80 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -460,6 +460,10 @@ components: type: boolean description: If true, only GET and OPTIONS test cases are generated. POST, PUT, PATCH and DELETE are excluded. example: false + serverPattern: + type: string + description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. + example: "%dev%" Headers: type: array description: List of optionals headers. This apply in all resources From 59516e2b3d43e4a8d1a69bb05e344c6eca38c074 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Wed, 1 Jul 2026 10:23:15 -0500 Subject: [PATCH 03/11] feat: add minimalEndpoints parameter to generate query parameter test variants --- CHANGELOG.md | 7 +- .../openapi2soapui/constants/Constants.java | 6 + .../controller/SoapUIProjectController.java | 2 +- .../openapi2soapui/model/SoapUIProject.java | 134 ++++++++++- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectService.java | 2 +- .../service/SoapUIProjectServiceImpl.java | 4 +- .../util/QueryParamExampleUtils.java | 111 ++++++++++ src/main/resources/static/api.yaml | 8 + .../util/QueryParamExampleUtilsTest.java | 208 ++++++++++++++++++ 10 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c090af..d2f0536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,9 @@ 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.2.0] - 2026-06-30 - -### Added -- Added `serverPattern` parameter: optional string (e.g. `%dev%`) that selects the OpenAPI server whose URL contains the given substring (after stripping `%`). If no server matches, the first server in the list is used as fallback. When omitted, all servers are added as endpoints (existing behavior preserved). - ## [1.1.0] - 2026-06-30 ### Added - Added `readOnly` parameter: when set to `true`, only GET and OPTIONS test cases are generated; POST, PUT, PATCH and DELETE operations are excluded. +- Added `serverPattern` parameter: optional string (e.g. `%dev%`) that selects the OpenAPI server whose URL contains the given substring (after stripping `%`). If no server matches, the first server in the list is used as fallback. When omitted, all servers are added as endpoints (existing behavior preserved). +- Added `minimalEndpoints` parameter: when set to `false` (default), two additional test cases are generated per optional query parameter of each operation — one with a valid value asserting HTTP status 200, and one with an invalid value asserting HTTP status 400. When `true`, only the `testCaseNames`-based test cases are generated. 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..d012ce1 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,10 @@ 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 QUERY_PARAM_VARIANT_PREFIX = "queryString "; + public static final String QUERY_PARAM_VARIANT_WRONG_SUFFIX = " wrong"; } 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 7dafa27..6d64936 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java @@ -36,7 +36,7 @@ public String newSoapUIProject(@Valid @RequestBody SoapUIProjectRequest newSoapU } SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject.getApiName(), openAPI, newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames(), - newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern()); + newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern(), newSoapUIProject.getMinimalEndpoints()); 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 0d2be9a..feda1f6 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,11 @@ 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.QUERY_PARAM_VARIANT_PREFIX; +import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.QUERY_PARAM_VARIANT_WRONG_SUFFIX; import java.io.File; import java.io.IOException; @@ -22,6 +27,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -59,7 +65,10 @@ 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.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; @@ -88,6 +97,7 @@ import lombok.Getter; import org.apiaddicts.apitools.openapi2soapui.request.GrantType; import org.apiaddicts.apitools.openapi2soapui.request.Header; +import org.apiaddicts.apitools.openapi2soapui.util.QueryParamExampleUtils; import org.apiaddicts.apitools.openapi2soapui.util.RefResolver; /** @@ -132,7 +142,17 @@ public class SoapUIProject { * 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; + /** + * 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<>(); + /** * SoapUIProject constructor * Set default test case names if testCaseNames is null or empty @@ -150,11 +170,12 @@ public class SoapUIProject { * @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 * @throws IOException * @throws XmlException * @throws SoapUIException */ - public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly, String serverPattern) throws IOException, XmlException, SoapUIException { + public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -168,7 +189,8 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List { 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(); @@ -724,12 +747,117 @@ private void setTestCases() { TestStepConfig ejecutionTestStepConfig = RestRequestStepFactory.createConfig(restMethod.getRequestByName(DEFAULT_REQUEST_NAME), EJECUTION_TEST_STEP + "_" + STEP_SUFFIX); testCase.addTestStep(ejecutionTestStepConfig); } + if (!minimalEndpoints) { + addQueryParamVariantTestCases(restResource, restMethod, testSuite); + } }); } }); } } + /** + * Add Query Param Variant Test Cases + * For each optional query parameter of the Operation bound to this Method, add a valid and an invalid + * test case, each asserting the corresponding HTTP status code (200 or 400) + * @param restResource instance of Resource owning the Method + * @param restMethod instance of Method to generate variants for + * @param testSuite Test Suite to add the variant Test Cases to + */ + private void addQueryParamVariantTestCases(RestResource restResource, RestMethod restMethod, WsdlTestSuite testSuite) { + Operation operation = operationByMethodKey.get(methodKey(restResource.getPath(), restMethod.getMethod().name())); + if (operation == null) return; + List queryParams = getQueryParameters(operation.getParameters()); + RestRequest defaultRequest = restMethod.getRequestByName(DEFAULT_REQUEST_NAME); + getOptionalParameters(queryParams).forEach(param -> { + addQueryParamVariantTestCase(restMethod, defaultRequest, testSuite, queryParams, param, false); + addQueryParamVariantTestCase(restMethod, defaultRequest, testSuite, queryParams, param, true); + }); + } + + /** + * Build a stable key identifying a Method within the SoapUI Project, independent of object identity, + * to bridge OpenAPI Operation data between setMethodsRequests and setTestCases + * @param path Resource path + * @param httpMethod HTTP method name + * @return composite key + */ + private String methodKey(String path, String httpMethod) { + return path + "#" + httpMethod; + } + + /** + * Get Query Parameters + * Filter OpenAPI Parameters to keep only the ones in the query + * @param parameters list of OpenAPI Parameters to filter + * @return list of query Parameters + */ + private List getQueryParameters(List parameters) { + if (parameters == null) return Collections.emptyList(); + return parameters.stream() + .filter(param -> QUERY.equalsIgnoreCase(param.getIn())) + .collect(Collectors.toList()); + } + + /** + * Get Optional Parameters + * Filter Parameters to keep only the ones that are not required + * @param parameters list of Parameters to filter + * @return list of optional Parameters + */ + private List getOptionalParameters(List parameters) { + return parameters.stream() + .filter(param -> !Boolean.TRUE.equals(param.getRequired())) + .collect(Collectors.toList()); + } + + /** + * Add Query Param Variant Test Case + * Clone the default Request (carrying over its endpoint, auth, media type, body and headers), then + * explicitly set every query parameter to a valid value, except the targeted parameter which gets + * an invalid value on the "wrong" variant + * Add a Test Case with this Request and a "Valid HTTP Status Codes" assertion for the expected status code + * @param restMethod instance of Method to add the variant Request to + * @param defaultRequest the Method's default Request, cloned as the base for the variant Request + * @param testSuite Test Suite to add the variant Test Case to + * @param queryParams all query Parameters of the Operation, so every one can be given an explicit valid value + * @param targetParam optional query Parameter being varied + * @param wrong when true, generates the invalid-value variant (expects 400), otherwise the valid-value variant (expects 200) + */ + private void addQueryParamVariantTestCase(RestMethod restMethod, RestRequest defaultRequest, WsdlTestSuite testSuite, + List queryParams, Parameter targetParam, boolean wrong) { + String requestName = QUERY_PARAM_VARIANT_PREFIX + targetParam.getName() + (wrong ? QUERY_PARAM_VARIANT_WRONG_SUFFIX : ""); + RestRequest variantRequest = restMethod.cloneRequest(defaultRequest, requestName); + + queryParams.forEach(param -> { + boolean isTarget = param.getName().equals(targetParam.getName()); + String value = (isTarget && wrong) ? QueryParamExampleUtils.invalidValue(param.getSchema()) : getValidQueryParamValue(param); + variantRequest.setPropertyValue(param.getName(), value); + }); + + String testCaseName = requestName + "_" + CASE_SUFFIX; + WsdlTestCase testCase = testSuite.addNewTestCase(testCaseName); + TestStepConfig stepConfig = RestRequestStepFactory.createConfig(variantRequest, EJECUTION_TEST_STEP + "_" + STEP_SUFFIX); + WsdlTestStep testStep = testCase.addTestStep(stepConfig); + + ValidHttpStatusCodesAssertion assertion = (ValidHttpStatusCodesAssertion) + ((RestTestRequestStep) testStep).addAssertion(VALID_HTTP_STATUS_CODES_ASSERTION); + assertion.setCodes(wrong ? WRONG_STATUS_CODE : SUCCESS_STATUS_CODE); + } + + /** + * Get Valid Query Param Value + * Prefer the OpenAPI Parameter example (example/examples/x-example), falling back to a type-aware + * generic value (honoring enum values when present) when no example is defined + * @param param OpenAPI Parameter to compute a valid value for + * @return valid value as String + */ + private String getValidQueryParamValue(Parameter param) { + Object example = getParameterExample(param); + if (example != null && !example.toString().isBlank()) return example.toString(); + return QueryParamExampleUtils.validValue(param.getSchema()); + } + /** * Get content of SoapUI Project File (XML) * @return SoapUI Project file content diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index c16687e..76fc105 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -43,4 +43,7 @@ public class SoapUIProjectRequest { @JsonProperty("serverPattern") private String serverPattern; + + @JsonProperty("minimalEndpoints") + private Boolean minimalEndpoints; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index cd856e8..649098b 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -12,5 +12,5 @@ import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 7e3d46c..6239396 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -18,8 +18,8 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames, Boolean readOnly, String serverPattern) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern); + Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints); } } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java new file mode 100644 index 0000000..c1e3fe9 --- /dev/null +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java @@ -0,0 +1,111 @@ +package org.apiaddicts.apitools.openapi2soapui.util; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.Date; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.DateSchema; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.NumberSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; + +public final class QueryParamExampleUtils { + + private QueryParamExampleUtils() { + // Intentional blank + } + + private static final String DATE_TIME_FORMAT = "date-time"; + private static final String EMAIL_FORMAT = "email"; + private static final String URI_FORMAT = "uri"; + private static final String URL_FORMAT = "url"; + private static final String UUID_FORMAT = "uuid"; + private static final String HOSTNAME_FORMAT = "hostname"; + private static final String IPV4_FORMAT = "ipv4"; + private static final String IPV6_FORMAT = "ipv6"; + private static final String BYTE_FORMAT = "byte"; + + public static String validValue(Schema schema) { + if (schema == null) return "value"; + if (schema instanceof DateSchema) return formatDateExample((DateSchema) schema, "2024-01-01"); + if (schema.getExample() != null) return schema.getExample().toString(); + if (schema.getEnum() != null && !schema.getEnum().isEmpty()) return schema.getEnum().get(0).toString(); + if (schema instanceof StringSchema) return validStringValue((StringSchema) schema); + if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return "1"; + if (schema instanceof BooleanSchema) return "true"; + if (schema instanceof ArraySchema) return "[]"; + if (schema instanceof ObjectSchema) return "{}"; + return "value"; + } + + public static String invalidValue(Schema schema) { + if (schema == null) return "badvalue"; + if (schema instanceof StringSchema) return invalidString((StringSchema) schema); + if (schema instanceof DateSchema) return invalidDate((DateSchema) schema); + if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return invalidNumber(schema); + if (schema instanceof BooleanSchema) return "badboolean"; + if (schema instanceof ArraySchema) return "badarray"; + if (schema instanceof ObjectSchema) return "badobject"; + return "badvalue"; + } + + private static String validStringValue(StringSchema schema) { + String format = schema.getFormat(); + if (DATE_TIME_FORMAT.equalsIgnoreCase(format)) return "2024-01-01T00:00:00Z"; + if (EMAIL_FORMAT.equalsIgnoreCase(format)) return "user@example.com"; + if (URI_FORMAT.equalsIgnoreCase(format) || URL_FORMAT.equalsIgnoreCase(format)) return "https://example.com"; + if (UUID_FORMAT.equalsIgnoreCase(format)) return "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + if (HOSTNAME_FORMAT.equalsIgnoreCase(format)) return "example.com"; + if (IPV4_FORMAT.equalsIgnoreCase(format)) return "192.168.0.1"; + if (IPV6_FORMAT.equalsIgnoreCase(format)) return "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + if (BYTE_FORMAT.equalsIgnoreCase(format)) return "SGVsbG8gV29ybGQ="; + return "string"; + } + + private static String invalidString(StringSchema schema) { + String format = schema.getFormat(); + if (DATE_TIME_FORMAT.equalsIgnoreCase(format)) return invalidDateTime(schema); + if (EMAIL_FORMAT.equalsIgnoreCase(format)) return "not-an-email"; + if (URI_FORMAT.equalsIgnoreCase(format) || URL_FORMAT.equalsIgnoreCase(format)) return "not a uri"; + if (UUID_FORMAT.equalsIgnoreCase(format)) return "not-a-valid-uuid"; + if (HOSTNAME_FORMAT.equalsIgnoreCase(format)) return "invalid_hostname!"; + if (IPV4_FORMAT.equalsIgnoreCase(format)) return "999.999.999.999"; + if (IPV6_FORMAT.equalsIgnoreCase(format)) return "not:a:valid:ipv6:zzzz"; + if (BYTE_FORMAT.equalsIgnoreCase(format)) return "not_base64!!!"; + Integer maxLength = schema.getMaxLength(); + return (maxLength != null && maxLength > 0) ? "z".repeat(maxLength + 1) : "badstring"; + } + + private static String invalidDateTime(StringSchema schema) { + Object example = schema.getExample(); + String base = (example != null) ? example.toString() : "2024-01-01T00:00:00Z"; + String withInvalidMonth = base.replaceFirst("^(\\d{4})-\\d{2}", "$1-50"); + return withInvalidMonth.equals(base) ? "baddatetime" : withInvalidMonth; + } + + private static String invalidNumber(Schema schema) { + if (schema.getMaximum() != null) return schema.getMaximum().add(BigDecimal.ONE).toString(); + if (schema.getMinimum() != null) return schema.getMinimum().subtract(BigDecimal.ONE).toString(); + return "badnumber"; + } + + private static String invalidDate(DateSchema schema) { + String base = formatDateExample(schema, "2024-01-01"); + String[] parts = base.split("-"); + if (parts.length == 3) { + parts[1] = "50"; + return String.join("-", parts); + } + return "baddate"; + } + + private static String formatDateExample(DateSchema schema, String fallback) { + Object example = schema.getExample(); + if (example instanceof Date) return new SimpleDateFormat("yyyy-MM-dd").format((Date) example); + return (example != null) ? example.toString() : fallback; + } +} diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index a978d80..d101006 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -42,6 +42,7 @@ paths: - Test Suite: {path}\_{httpMethodInUppercase}\_TestSuite - Test Case (Default): Success\_TestCase - Test Case: {testCaseName}\_TestCase + - Test Case (Query Param Variant): queryString {paramName}\_TestCase / queryString {paramName} wrong\_TestCase - Test Step: Execution\_TestStep The variables are obtained from: @@ -58,6 +59,8 @@ paths: Additionally, when the **readOnly** flag is set to true, only GET and OPTIONS test cases are generated; POST, PUT, PATCH and DELETE operations are excluded. + Additionally, when the **minimalEndpoints** flag is set to false (the default), for each optional query parameter of an operation, two extra test cases are generated: one with a valid value (asserting HTTP status 200) and one with an invalid value (asserting HTTP status 400). Set to true to only generate the testCaseNames-based test cases. + The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. requestBody: description: | @@ -70,6 +73,7 @@ paths: - testCaseNames: List with the names of the test cases - headers: List of custom headers - readOnly: Boolean flag to generate only read operations (GET/OPTIONS) + - minimalEndpoints: Boolean flag; when false (default), also generates a valid/invalid test case pair per optional query parameter content: aplication/json: schema: @@ -460,6 +464,10 @@ components: type: boolean description: If true, only GET and OPTIONS test cases are generated. POST, PUT, PATCH and DELETE are excluded. example: false + minimalEndpoints: + type: boolean + description: If false (default), generates 2 additional test cases per optional query parameter — one with a valid value (expects 200) and one with an invalid value (expects 400). If true, only testCaseNames-based test cases are generated. + example: false serverPattern: type: string description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java new file mode 100644 index 0000000..4c5ae6c --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java @@ -0,0 +1,208 @@ +package org.apiaddicts.apitools.openapi2soapui.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.DateSchema; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.NumberSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.StringSchema; + +class QueryParamExampleUtilsTest { + + @Test + void invalidValue_returnsGenericValue_whenSchemaIsNull() { + assertEquals("badvalue", QueryParamExampleUtils.invalidValue(null)); + } + + @Test + void invalidValue_padsToMaxLengthPlusOne_whenStringHasMaxLength() { + StringSchema schema = new StringSchema(); + schema.setMaxLength(5); + assertEquals(6, QueryParamExampleUtils.invalidValue(schema).length()); + } + + @Test + void invalidValue_returnsGenericString_whenStringHasNoMaxLength() { + StringSchema schema = new StringSchema(); + assertEquals("badstring", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void invalidValue_exceedsMaximum_whenIntegerHasMaximum() { + IntegerSchema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.TEN); + assertEquals("11", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void invalidValue_goesBelowMinimum_whenNumberHasOnlyMinimum() { + NumberSchema schema = new NumberSchema(); + schema.setMinimum(BigDecimal.ZERO); + assertEquals("-1", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void invalidValue_returnsGenericNumber_whenNoBoundsDefined() { + IntegerSchema schema = new IntegerSchema(); + assertEquals("badnumber", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void invalidValue_returnsBadBoolean_forBooleanSchema() { + assertEquals("badboolean", QueryParamExampleUtils.invalidValue(new BooleanSchema())); + } + + @Test + void invalidValue_returnsBadArray_forArraySchema() { + assertEquals("badarray", QueryParamExampleUtils.invalidValue(new ArraySchema())); + } + + @Test + void invalidValue_returnsBadObject_forObjectSchema() { + assertEquals("badobject", QueryParamExampleUtils.invalidValue(new ObjectSchema())); + } + + @Test + void invalidValue_setsInvalidMonth_forDateSchema() { + DateSchema schema = new DateSchema(); + schema.setExample("2024-01-01"); + String[] parts = QueryParamExampleUtils.invalidValue(schema).split("-"); + assertEquals(3, parts.length); + assertEquals("50", parts[1]); + } + + @Test + void invalidValue_setsInvalidMonth_forDateSchemaWithoutExample() { + String[] parts = QueryParamExampleUtils.invalidValue(new DateSchema()).split("-"); + assertEquals(3, parts.length); + assertEquals("50", parts[1]); + } + + @Test + void validValue_returnsGenericValue_whenSchemaIsNull() { + assertEquals("value", QueryParamExampleUtils.validValue(null)); + } + + @Test + void validValue_returnsExample_whenSchemaHasExample() { + StringSchema schema = new StringSchema(); + schema.setExample("foo"); + assertEquals("foo", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void validValue_returnsTypedDefault_forEachSchemaType() { + assertEquals("string", QueryParamExampleUtils.validValue(new StringSchema())); + assertTrue(QueryParamExampleUtils.validValue(new IntegerSchema()).matches("\\d+")); + assertEquals("true", QueryParamExampleUtils.validValue(new BooleanSchema())); + } + + @Test + void validValue_returnsFirstEnumValue_whenSchemaHasEnumAndNoExample() { + StringSchema schema = new StringSchema(); + schema.setEnum(java.util.List.of("asc", "desc")); + assertEquals("asc", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void validValue_prefersExampleOverEnum() { + StringSchema schema = new StringSchema(); + schema.setEnum(java.util.List.of("asc", "desc")); + schema.setExample("desc"); + assertEquals("desc", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void validValue_returnsIsoDateTime_forDateTimeFormattedString() { + StringSchema schema = new StringSchema(); + schema.setFormat("date-time"); + assertEquals("2024-01-01T00:00:00Z", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void invalidValue_setsInvalidMonth_forDateTimeFormattedString() { + StringSchema schema = new StringSchema(); + schema.setFormat("date-time"); + assertEquals("2024-50-01T00:00:00Z", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void invalidValue_setsInvalidMonth_forDateTimeFormattedStringWithExample() { + StringSchema schema = new StringSchema(); + schema.setFormat("date-time"); + schema.setExample("2025-06-15T10:30:00Z"); + assertEquals("2025-50-15T10:30:00Z", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void validValue_returnsRealisticValue_forEmailFormat() { + StringSchema schema = new StringSchema(); + schema.setFormat("email"); + assertEquals("user@example.com", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void invalidValue_hasNoAtSign_forEmailFormat() { + StringSchema schema = new StringSchema(); + schema.setFormat("email"); + assertTrue(!QueryParamExampleUtils.invalidValue(schema).contains("@")); + } + + @Test + void validValue_returnsRealisticValue_forUriFormat() { + StringSchema schema = new StringSchema(); + schema.setFormat("uri"); + assertEquals("https://example.com", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void validValue_returnsRealisticValue_forUuidFormat() { + StringSchema schema = new StringSchema(); + schema.setFormat("uuid"); + assertEquals("3fa85f64-5717-4562-b3fc-2c963f66afa6", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void invalidValue_isNotUuidShaped_forUuidFormat() { + StringSchema schema = new StringSchema(); + schema.setFormat("uuid"); + assertEquals("not-a-valid-uuid", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void validValue_returnsRealisticValue_forIpv4Format() { + StringSchema schema = new StringSchema(); + schema.setFormat("ipv4"); + assertEquals("192.168.0.1", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void invalidValue_hasOutOfRangeOctets_forIpv4Format() { + StringSchema schema = new StringSchema(); + schema.setFormat("ipv4"); + assertEquals("999.999.999.999", QueryParamExampleUtils.invalidValue(schema)); + } + + @Test + void validValue_returnsRealisticValue_forByteFormat() { + StringSchema schema = new StringSchema(); + schema.setFormat("byte"); + assertEquals("SGVsbG8gV29ybGQ=", QueryParamExampleUtils.validValue(schema)); + } + + @Test + void formatAwareness_stillYieldsToExplicitExample() { + StringSchema schema = new StringSchema(); + schema.setFormat("email"); + schema.setExample("custom@example.org"); + assertEquals("custom@example.org", QueryParamExampleUtils.validValue(schema)); + } +} From dc8a4369a9d529c5b558c96e9dd749c627f835b5 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Wed, 1 Jul 2026 13:11:49 -0500 Subject: [PATCH 04/11] feat: add microcksHeaders parameter to add Microcks response-name header --- CHANGELOG.md | 1 + .../openapi2soapui/constants/Constants.java | 2 + .../controller/SoapUIProjectController.java | 3 +- .../openapi2soapui/model/SoapUIProject.java | 63 +++++++++++++++++-- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectService.java | 2 +- .../service/SoapUIProjectServiceImpl.java | 4 +- src/main/resources/static/api.yaml | 7 +++ 8 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f0536..42addc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `readOnly` parameter: when set to `true`, only GET and OPTIONS test cases are generated; POST, PUT, PATCH and DELETE operations are excluded. - Added `serverPattern` parameter: optional string (e.g. `%dev%`) that selects the OpenAPI server whose URL contains the given substring (after stripping `%`). If no server matches, the first server in the list is used as fallback. When omitted, all servers are added as endpoints (existing behavior preserved). - Added `minimalEndpoints` parameter: when set to `false` (default), two additional test cases are generated per optional query parameter of each operation — one with a valid value asserting HTTP status 200, and one with an invalid value asserting HTTP status 400. When `true`, only the `testCaseNames`-based test cases are generated. +- Added `microcksHeaders` parameter: when set to `true`, adds an `X-Microcks-Response-Name` header to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the `default` response), or `default` if none is defined. 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 d012ce1..3d3a90a 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java @@ -36,4 +36,6 @@ private Constants() { public static final String WRONG_STATUS_CODE = "400"; 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 6d64936..da50880 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java @@ -36,7 +36,8 @@ public String newSoapUIProject(@Valid @RequestBody SoapUIProjectRequest newSoapU } SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject.getApiName(), openAPI, newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames(), - newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern(), newSoapUIProject.getMinimalEndpoints()); + newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern(), newSoapUIProject.getMinimalEndpoints(), + newSoapUIProject.getMicrocksHeaders()); 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 feda1f6..2602203 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -18,6 +18,7 @@ import static org.apiaddicts.apitools.openapi2soapui.constants.Constants.WRONG_STATUS_CODE; 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; @@ -146,6 +147,10 @@ public class SoapUIProject { * 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; /** * 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 @@ -171,11 +176,12 @@ public class SoapUIProject { * @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 * @throws IOException * @throws XmlException * @throws SoapUIException */ - public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints) throws IOException, XmlException, SoapUIException { + public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -190,6 +196,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List 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 diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index 76fc105..13172a6 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -46,4 +46,7 @@ public class SoapUIProjectRequest { @JsonProperty("minimalEndpoints") private Boolean minimalEndpoints; + + @JsonProperty("microcksHeaders") + private Boolean microcksHeaders; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index 649098b..c25408c 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -12,5 +12,5 @@ import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 6239396..60e2b97 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -18,8 +18,8 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints); + Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints, microcksHeaders); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index d101006..f490932 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -61,6 +61,8 @@ paths: Additionally, when the **minimalEndpoints** flag is set to false (the default), for each optional query parameter of an operation, two extra test cases are generated: one with a valid value (asserting HTTP status 200) and one with an invalid value (asserting HTTP status 400). Set to true to only generate the testCaseNames-based test cases. + Additionally, when the **microcksHeaders** flag is set to true, an X-Microcks-Response-Name header is added to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the "default" response), or "default" if none is defined. + The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. requestBody: description: | @@ -74,6 +76,7 @@ paths: - headers: List of custom headers - readOnly: Boolean flag to generate only read operations (GET/OPTIONS) - minimalEndpoints: Boolean flag; when false (default), also generates a valid/invalid test case pair per optional query parameter + - microcksHeaders: Boolean flag; when true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers content: aplication/json: schema: @@ -468,6 +471,10 @@ components: type: boolean description: If false (default), generates 2 additional test cases per optional query parameter — one with a valid value (expects 200) and one with an invalid value (expects 400). If true, only testCaseNames-based test cases are generated. example: false + microcksHeaders: + type: boolean + description: If true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the "default" response), or "default" if none is defined. + example: false serverPattern: type: string description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. From 5d2e5d0c6f5f99c891ac21ed4e1148b3090af3e1 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Wed, 1 Jul 2026 16:48:12 -0500 Subject: [PATCH 05/11] feat: add generateOneOfAnyOf parameter to resolve oneOf/anyOf/allOf schemas in example bodies --- CHANGELOG.md | 1 + .../controller/SoapUIProjectController.java | 2 +- .../openapi2soapui/model/SoapUIProject.java | 58 ++++++++++++++++++- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectService.java | 2 +- .../service/SoapUIProjectServiceImpl.java | 4 +- src/main/resources/static/api.yaml | 7 +++ 7 files changed, 70 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42addc4..7ff54fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `serverPattern` parameter: optional string (e.g. `%dev%`) that selects the OpenAPI server whose URL contains the given substring (after stripping `%`). If no server matches, the first server in the list is used as fallback. When omitted, all servers are added as endpoints (existing behavior preserved). - Added `minimalEndpoints` parameter: when set to `false` (default), two additional test cases are generated per optional query parameter of each operation — one with a valid value asserting HTTP status 200, and one with an invalid value asserting HTTP status 400. When `true`, only the `testCaseNames`-based test cases are generated. - Added `microcksHeaders` parameter: when set to `true`, adds an `X-Microcks-Response-Name` header to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the `default` response), or `default` if none is defined. +- Added `generateOneOfAnyOf` parameter: when set to `true`, `oneOf`/`anyOf` schemas are resolved to their first candidate schema when generating the example request body (default: `false`, left unresolved). `allOf` schemas are always merged into a single object, regardless of this flag. 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 da50880..7fa9e5c 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java @@ -37,7 +37,7 @@ public String newSoapUIProject(@Valid @RequestBody SoapUIProjectRequest newSoapU SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject.getApiName(), openAPI, newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames(), newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern(), newSoapUIProject.getMinimalEndpoints(), - newSoapUIProject.getMicrocksHeaders()); + newSoapUIProject.getMicrocksHeaders(), newSoapUIProject.getGenerateOneOfAnyOf()); 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 2602203..a306d4c 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -151,6 +151,11 @@ public class SoapUIProject { * 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; /** * 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 @@ -177,11 +182,12 @@ public class SoapUIProject { * @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 * @throws IOException * @throws XmlException * @throws SoapUIException */ - public SoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders) 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) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -197,6 +203,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List 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()) { @@ -624,8 +631,9 @@ private JSONObject iterateProperties(Map properties, RefResolver @SuppressWarnings("rawtypes") private Object getPropertyExample(Schema property, RefResolver refResolver) throws JSONException { Object example = property.getExample(); - + if (example == null) { + property = resolveComposedSchema(property, refResolver); if (property instanceof ObjectSchema) { example = iterateProperties(((ObjectSchema) property).getProperties(), refResolver); } else if (property instanceof ArraySchema) { @@ -657,6 +665,50 @@ private Object getPropertyExample(Schema property, RefResolver refResolver) thro return example; } + /** + * 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 = schema; + List allOf = schema.getAllOf(); + if (allOf != null && !allOf.isEmpty()) { + 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); + resolved = merged; + } else if (generateOneOfAnyOf && schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + resolved = refResolver.resolveSchema((Schema) schema.getOneOf().get(0)); + } else if (generateOneOfAnyOf && schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + resolved = refResolver.resolveSchema((Schema) schema.getAnyOf().get(0)); + } + if (resolved == schema) { + return resolved; + } + schema = resolved; + } + return schema; + } + /** * Convert Object or JSONObject to JSON String * @param object to convert diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index 13172a6..34ed927 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -49,4 +49,7 @@ public class SoapUIProjectRequest { @JsonProperty("microcksHeaders") private Boolean microcksHeaders; + + @JsonProperty("generateOneOfAnyOf") + private Boolean generateOneOfAnyOf; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index c25408c..26a2778 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -12,5 +12,5 @@ import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 60e2b97..e77a642 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -18,8 +18,8 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints, microcksHeaders); + Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints, microcksHeaders, generateOneOfAnyOf); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index f490932..b5f7e6b 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -63,6 +63,8 @@ paths: Additionally, when the **microcksHeaders** flag is set to true, an X-Microcks-Response-Name header is added to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the "default" response), or "default" if none is defined. + Additionally, when the **generateOneOfAnyOf** flag is set to true, oneOf/anyOf schemas in the request body are resolved to their first candidate schema when generating the example body (default: unresolved/omitted). allOf schemas are always merged into a single object regardless of this flag. + The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. requestBody: description: | @@ -77,6 +79,7 @@ paths: - readOnly: Boolean flag to generate only read operations (GET/OPTIONS) - minimalEndpoints: Boolean flag; when false (default), also generates a valid/invalid test case pair per optional query parameter - microcksHeaders: Boolean flag; when true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers + - generateOneOfAnyOf: Boolean flag; when true, resolves oneOf/anyOf schemas to their first candidate when generating example bodies. allOf is always merged regardless of this flag content: aplication/json: schema: @@ -475,6 +478,10 @@ components: type: boolean description: If true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the "default" response), or "default" if none is defined. example: false + generateOneOfAnyOf: + type: boolean + description: If true, resolves oneOf/anyOf schemas to their first candidate schema when generating the example request body. allOf schemas are always merged into a single object regardless of this flag. + example: false serverPattern: type: string description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. From 4f7d091797c3cf50909e28c03435400daa0d9a1e Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Thu, 2 Jul 2026 08:50:37 -0500 Subject: [PATCH 06/11] feat: add examples parameter for custom sample values --- CHANGELOG.md | 13 +- pom.xml | 5 +- .../controller/SoapUIProjectController.java | 2 +- .../openapi2soapui/model/SoapUIProject.java | 46 +++++- .../openapi2soapui/request/ExampleValues.java | 28 ++++ .../request/ExamplesConfig.java | 21 +++ .../request/SoapUIProjectRequest.java | 4 + .../service/SoapUIProjectService.java | 3 +- .../service/SoapUIProjectServiceImpl.java | 5 +- .../util/QueryParamExampleUtils.java | 55 ++++--- src/main/resources/static/api.yaml | 30 ++++ .../util/QueryParamExampleUtilsTest.java | 138 ++++++++++++++---- 12 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExampleValues.java create mode 100644 src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExamplesConfig.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff54fd..30f5741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,12 @@ 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] - 2026-06-30 +## [1.1.0-beta-1] - 2026-07-02 ### Added -- Added `readOnly` parameter: when set to `true`, only GET and OPTIONS test cases are generated; POST, PUT, PATCH and DELETE operations are excluded. -- Added `serverPattern` parameter: optional string (e.g. `%dev%`) that selects the OpenAPI server whose URL contains the given substring (after stripping `%`). If no server matches, the first server in the list is used as fallback. When omitted, all servers are added as endpoints (existing behavior preserved). -- Added `minimalEndpoints` parameter: when set to `false` (default), two additional test cases are generated per optional query parameter of each operation — one with a valid value asserting HTTP status 200, and one with an invalid value asserting HTTP status 400. When `true`, only the `testCaseNames`-based test cases are generated. -- Added `microcksHeaders` parameter: when set to `true`, adds an `X-Microcks-Response-Name` header to each request, in addition to any custom headers. Its value is the name of the response example defined in the OpenAPI spec (first 2xx response, falling back to the `default` response), or `default` if none is defined. -- Added `generateOneOfAnyOf` parameter: when set to `true`, `oneOf`/`anyOf` schemas are resolved to their first candidate schema when generating the example request body (default: `false`, left unresolved). `allOf` schemas are always merged into a single object, regardless of this flag. +- `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. diff --git a/pom.xml b/pom.xml index 93fc651..fa29395 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ net.cloudappi openapi2soapui - 1.0.3 + 1.1.0-beta-1 ${packaging.type} openapi2soapui @@ -52,7 +52,8 @@ 1.5.6 5.6.0 - 2.0.24 + 2.1.39 + 1.18.30 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 7fa9e5c..fa2086a 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/controller/SoapUIProjectController.java @@ -37,7 +37,7 @@ public String newSoapUIProject(@Valid @RequestBody SoapUIProjectRequest newSoapU SoapUIProject soapUIProject = soapUIProjectService.createSoapUIProject(newSoapUIProject.getApiName(), openAPI, newSoapUIProject.getOAuth2Profiles(), newSoapUIProject.getHeaders(), newSoapUIProject.getTestCaseNames(), newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern(), newSoapUIProject.getMinimalEndpoints(), - newSoapUIProject.getMicrocksHeaders(), newSoapUIProject.getGenerateOneOfAnyOf()); + newSoapUIProject.getMicrocksHeaders(), newSoapUIProject.getGenerateOneOfAnyOf(), newSoapUIProject.getExamples()); 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 a306d4c..55086fa 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -98,6 +98,8 @@ 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; @@ -156,6 +158,10 @@ public class SoapUIProject { * allOf schemas are always merged into a single object, regardless of this flag. */ private boolean generateOneOfAnyOf; + /** + * 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 @@ -183,14 +189,16 @@ public class SoapUIProject { * @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 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, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf) 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, ExamplesConfig examples) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; + this.examples = examples; this.apiVersion = openAPI.getInfo().getVersion(); @@ -642,20 +650,22 @@ private Object getPropertyExample(Schema property, RefResolver refResolver) thro jsonArray.put(getPropertyExample(items, refResolver)); example = jsonArray; } else if (property instanceof IntegerSchema) { - example = 0; + example = getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO); } else if (property instanceof NumberSchema) { - example = 0; + example = getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO); } else if (property instanceof BooleanSchema) { - example = true; + example = getConfiguredExample(false, ExampleValues::getBooleanValue, true); } else if (property instanceof DateSchema) { - example = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); + example = getConfiguredExample(false, ExampleValues::getDate, 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 if ("date-time".equalsIgnoreCase(stringProperty.getFormat())) { + example = getConfiguredExample(false, ExampleValues::getDateTime, ""); } else { - example = ""; + example = getConfiguredExample(false, ExampleValues::getString, ""); } } else { example = ""; @@ -665,6 +675,26 @@ private Object getPropertyExample(Schema property, RefResolver refResolver) thro return example; } + /** + * 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 @@ -938,7 +968,7 @@ private void addQueryParamVariantTestCase(RestMethod restMethod, RestRequest def queryParams.forEach(param -> { boolean isTarget = param.getName().equals(targetParam.getName()); - String value = (isTarget && wrong) ? QueryParamExampleUtils.invalidValue(param.getSchema()) : getValidQueryParamValue(param); + String value = (isTarget && wrong) ? QueryParamExampleUtils.invalidValue(param.getSchema(), examples != null ? examples.getWrong() : null) : getValidQueryParamValue(param); variantRequest.setPropertyValue(param.getName(), value); }); @@ -962,7 +992,7 @@ private void addQueryParamVariantTestCase(RestMethod restMethod, RestRequest def private String getValidQueryParamValue(Parameter param) { Object example = getParameterExample(param); if (example != null && !example.toString().isBlank()) return example.toString(); - return QueryParamExampleUtils.validValue(param.getSchema()); + return QueryParamExampleUtils.validValue(param.getSchema(), examples != null ? examples.getSuccessful() : null); } /** diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExampleValues.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExampleValues.java new file mode 100644 index 0000000..a606077 --- /dev/null +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExampleValues.java @@ -0,0 +1,28 @@ +package org.apiaddicts.apitools.openapi2soapui.request; + +import java.math.BigDecimal; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ExampleValues { + + @JsonProperty("string") + private String string; + + @JsonProperty("number") + private BigDecimal number; + + @JsonProperty("boolean") + private Boolean booleanValue; + + @JsonProperty("date") + private String date; + + @JsonProperty("dateTime") + private String dateTime; +} diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExamplesConfig.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExamplesConfig.java new file mode 100644 index 0000000..62bdf3c --- /dev/null +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/ExamplesConfig.java @@ -0,0 +1,21 @@ +package org.apiaddicts.apitools.openapi2soapui.request; + +import javax.validation.Valid; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ExamplesConfig { + + @Valid + @JsonProperty("successful") + private ExampleValues successful; + + @Valid + @JsonProperty("wrong") + private ExampleValues wrong; +} diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index 34ed927..d93d055 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -52,4 +52,8 @@ public class SoapUIProjectRequest { @JsonProperty("generateOneOfAnyOf") private Boolean generateOneOfAnyOf; + + @Valid + @JsonProperty("examples") + private ExamplesConfig examples; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index 26a2778..b616478 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -9,8 +9,9 @@ import org.apiaddicts.apitools.openapi2soapui.model.SoapUIProject; import org.apiaddicts.apitools.openapi2soapui.request.OAuth2Profile; import org.apiaddicts.apitools.openapi2soapui.request.Header; +import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig; import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, ExamplesConfig examples) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index e77a642..f8f4534 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -12,14 +12,15 @@ import org.apiaddicts.apitools.openapi2soapui.model.SoapUIProject; import org.apiaddicts.apitools.openapi2soapui.request.OAuth2Profile; import org.apiaddicts.apitools.openapi2soapui.request.Header; +import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig; @Service public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints, microcksHeaders, generateOneOfAnyOf); + Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, ExamplesConfig examples) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints, microcksHeaders, generateOneOfAnyOf, examples); } } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java index c1e3fe9..150dc95 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java @@ -13,6 +13,8 @@ import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; +import org.apiaddicts.apitools.openapi2soapui.request.ExampleValues; + public final class QueryParamExampleUtils { private QueryParamExampleUtils() { @@ -29,33 +31,39 @@ private QueryParamExampleUtils() { private static final String IPV6_FORMAT = "ipv6"; private static final String BYTE_FORMAT = "byte"; - public static String validValue(Schema schema) { + public static String validValue(Schema schema, ExampleValues successfulExamples) { if (schema == null) return "value"; - if (schema instanceof DateSchema) return formatDateExample((DateSchema) schema, "2024-01-01"); + if (schema instanceof DateSchema) return formatDateExample((DateSchema) schema, configuredOrDefault(successfulExamples, ExampleValues::getDate, "2024-01-01")); if (schema.getExample() != null) return schema.getExample().toString(); if (schema.getEnum() != null && !schema.getEnum().isEmpty()) return schema.getEnum().get(0).toString(); - if (schema instanceof StringSchema) return validStringValue((StringSchema) schema); - if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return "1"; - if (schema instanceof BooleanSchema) return "true"; + if (schema instanceof StringSchema) return validStringValue((StringSchema) schema, successfulExamples); + if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return configuredOrDefault(successfulExamples, v -> v.getNumber() != null ? v.getNumber().toString() : null, "1"); + if (schema instanceof BooleanSchema) return configuredOrDefault(successfulExamples, v -> v.getBooleanValue() != null ? v.getBooleanValue().toString() : null, "true"); if (schema instanceof ArraySchema) return "[]"; if (schema instanceof ObjectSchema) return "{}"; return "value"; } - public static String invalidValue(Schema schema) { + public static String invalidValue(Schema schema, ExampleValues wrongExamples) { if (schema == null) return "badvalue"; - if (schema instanceof StringSchema) return invalidString((StringSchema) schema); - if (schema instanceof DateSchema) return invalidDate((DateSchema) schema); - if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return invalidNumber(schema); - if (schema instanceof BooleanSchema) return "badboolean"; + if (schema instanceof StringSchema) return invalidString((StringSchema) schema, wrongExamples); + if (schema instanceof DateSchema) return invalidDate((DateSchema) schema, wrongExamples); + if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return invalidNumber(schema, wrongExamples); + if (schema instanceof BooleanSchema) return configuredOrDefault(wrongExamples, v -> v.getBooleanValue() != null ? v.getBooleanValue().toString() : null, "badboolean"); if (schema instanceof ArraySchema) return "badarray"; if (schema instanceof ObjectSchema) return "badobject"; return "badvalue"; } - private static String validStringValue(StringSchema schema) { + private static String configuredOrDefault(ExampleValues values, java.util.function.Function getter, String defaultValue) { + if (values == null) return defaultValue; + String configured = getter.apply(values); + return configured != null ? configured : defaultValue; + } + + private static String validStringValue(StringSchema schema, ExampleValues successfulExamples) { String format = schema.getFormat(); - if (DATE_TIME_FORMAT.equalsIgnoreCase(format)) return "2024-01-01T00:00:00Z"; + if (DATE_TIME_FORMAT.equalsIgnoreCase(format)) return configuredOrDefault(successfulExamples, ExampleValues::getDateTime, "2024-01-01T00:00:00Z"); if (EMAIL_FORMAT.equalsIgnoreCase(format)) return "user@example.com"; if (URI_FORMAT.equalsIgnoreCase(format) || URL_FORMAT.equalsIgnoreCase(format)) return "https://example.com"; if (UUID_FORMAT.equalsIgnoreCase(format)) return "3fa85f64-5717-4562-b3fc-2c963f66afa6"; @@ -63,12 +71,12 @@ private static String validStringValue(StringSchema schema) { if (IPV4_FORMAT.equalsIgnoreCase(format)) return "192.168.0.1"; if (IPV6_FORMAT.equalsIgnoreCase(format)) return "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; if (BYTE_FORMAT.equalsIgnoreCase(format)) return "SGVsbG8gV29ybGQ="; - return "string"; + return configuredOrDefault(successfulExamples, ExampleValues::getString, "string"); } - private static String invalidString(StringSchema schema) { + private static String invalidString(StringSchema schema, ExampleValues wrongExamples) { String format = schema.getFormat(); - if (DATE_TIME_FORMAT.equalsIgnoreCase(format)) return invalidDateTime(schema); + if (DATE_TIME_FORMAT.equalsIgnoreCase(format)) return invalidDateTime(schema, wrongExamples); if (EMAIL_FORMAT.equalsIgnoreCase(format)) return "not-an-email"; if (URI_FORMAT.equalsIgnoreCase(format) || URL_FORMAT.equalsIgnoreCase(format)) return "not a uri"; if (UUID_FORMAT.equalsIgnoreCase(format)) return "not-a-valid-uuid"; @@ -77,23 +85,30 @@ private static String invalidString(StringSchema schema) { if (IPV6_FORMAT.equalsIgnoreCase(format)) return "not:a:valid:ipv6:zzzz"; if (BYTE_FORMAT.equalsIgnoreCase(format)) return "not_base64!!!"; Integer maxLength = schema.getMaxLength(); - return (maxLength != null && maxLength > 0) ? "z".repeat(maxLength + 1) : "badstring"; + if (maxLength != null && maxLength > 0) return "z".repeat(maxLength + 1); + return configuredOrDefault(wrongExamples, ExampleValues::getString, "badstring"); } - private static String invalidDateTime(StringSchema schema) { + private static String invalidDateTime(StringSchema schema, ExampleValues wrongExamples) { Object example = schema.getExample(); + if (example == null && wrongExamples != null && wrongExamples.getDateTime() != null) { + return wrongExamples.getDateTime(); + } String base = (example != null) ? example.toString() : "2024-01-01T00:00:00Z"; String withInvalidMonth = base.replaceFirst("^(\\d{4})-\\d{2}", "$1-50"); return withInvalidMonth.equals(base) ? "baddatetime" : withInvalidMonth; } - private static String invalidNumber(Schema schema) { + private static String invalidNumber(Schema schema, ExampleValues wrongExamples) { if (schema.getMaximum() != null) return schema.getMaximum().add(BigDecimal.ONE).toString(); if (schema.getMinimum() != null) return schema.getMinimum().subtract(BigDecimal.ONE).toString(); - return "badnumber"; + return configuredOrDefault(wrongExamples, v -> v.getNumber() != null ? v.getNumber().toString() : null, "badnumber"); } - private static String invalidDate(DateSchema schema) { + private static String invalidDate(DateSchema schema, ExampleValues wrongExamples) { + if (schema.getExample() == null && wrongExamples != null && wrongExamples.getDate() != null) { + return wrongExamples.getDate(); + } String base = formatDateExample(schema, "2024-01-01"); String[] parts = base.split("-"); if (parts.length == 3) { diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index b5f7e6b..d54041f 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -486,6 +486,36 @@ components: type: string description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. example: "%dev%" + examples: + $ref: '#/components/schemas/Examples' + Examples: + type: object + description: Custom example values used when generating request body properties and optional-query-parameter test values. Any field not provided falls back to the tool's internal default. "successful" values are used for valid request bodies and valid query parameter test cases; "wrong" values are used only for the invalid query parameter test cases generated when minimalEndpoints is false. + properties: + successful: + $ref: '#/components/schemas/ExampleValues' + wrong: + $ref: '#/components/schemas/ExampleValues' + ExampleValues: + type: object + properties: + string: + type: string + example: goodstring + number: + type: number + example: 6 + boolean: + type: boolean + example: true + date: + type: string + format: date + example: "2020-01-01" + dateTime: + type: string + format: date-time + example: "2020-01-01T23:59:59" Headers: type: array description: List of optionals headers. This apply in all resources diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java index 4c5ae6c..c6a671d 100644 --- a/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java @@ -15,101 +15,103 @@ import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.StringSchema; +import org.apiaddicts.apitools.openapi2soapui.request.ExampleValues; + class QueryParamExampleUtilsTest { @Test void invalidValue_returnsGenericValue_whenSchemaIsNull() { - assertEquals("badvalue", QueryParamExampleUtils.invalidValue(null)); + assertEquals("badvalue", QueryParamExampleUtils.invalidValue(null, null)); } @Test void invalidValue_padsToMaxLengthPlusOne_whenStringHasMaxLength() { StringSchema schema = new StringSchema(); schema.setMaxLength(5); - assertEquals(6, QueryParamExampleUtils.invalidValue(schema).length()); + assertEquals(6, QueryParamExampleUtils.invalidValue(schema, null).length()); } @Test void invalidValue_returnsGenericString_whenStringHasNoMaxLength() { StringSchema schema = new StringSchema(); - assertEquals("badstring", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("badstring", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void invalidValue_exceedsMaximum_whenIntegerHasMaximum() { IntegerSchema schema = new IntegerSchema(); schema.setMaximum(BigDecimal.TEN); - assertEquals("11", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("11", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void invalidValue_goesBelowMinimum_whenNumberHasOnlyMinimum() { NumberSchema schema = new NumberSchema(); schema.setMinimum(BigDecimal.ZERO); - assertEquals("-1", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("-1", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void invalidValue_returnsGenericNumber_whenNoBoundsDefined() { IntegerSchema schema = new IntegerSchema(); - assertEquals("badnumber", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("badnumber", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void invalidValue_returnsBadBoolean_forBooleanSchema() { - assertEquals("badboolean", QueryParamExampleUtils.invalidValue(new BooleanSchema())); + assertEquals("badboolean", QueryParamExampleUtils.invalidValue(new BooleanSchema(), null)); } @Test void invalidValue_returnsBadArray_forArraySchema() { - assertEquals("badarray", QueryParamExampleUtils.invalidValue(new ArraySchema())); + assertEquals("badarray", QueryParamExampleUtils.invalidValue(new ArraySchema(), null)); } @Test void invalidValue_returnsBadObject_forObjectSchema() { - assertEquals("badobject", QueryParamExampleUtils.invalidValue(new ObjectSchema())); + assertEquals("badobject", QueryParamExampleUtils.invalidValue(new ObjectSchema(), null)); } @Test void invalidValue_setsInvalidMonth_forDateSchema() { DateSchema schema = new DateSchema(); schema.setExample("2024-01-01"); - String[] parts = QueryParamExampleUtils.invalidValue(schema).split("-"); + String[] parts = QueryParamExampleUtils.invalidValue(schema, null).split("-"); assertEquals(3, parts.length); assertEquals("50", parts[1]); } @Test void invalidValue_setsInvalidMonth_forDateSchemaWithoutExample() { - String[] parts = QueryParamExampleUtils.invalidValue(new DateSchema()).split("-"); + String[] parts = QueryParamExampleUtils.invalidValue(new DateSchema(), null).split("-"); assertEquals(3, parts.length); assertEquals("50", parts[1]); } @Test void validValue_returnsGenericValue_whenSchemaIsNull() { - assertEquals("value", QueryParamExampleUtils.validValue(null)); + assertEquals("value", QueryParamExampleUtils.validValue(null, null)); } @Test void validValue_returnsExample_whenSchemaHasExample() { StringSchema schema = new StringSchema(); schema.setExample("foo"); - assertEquals("foo", QueryParamExampleUtils.validValue(schema)); + assertEquals("foo", QueryParamExampleUtils.validValue(schema, null)); } @Test void validValue_returnsTypedDefault_forEachSchemaType() { - assertEquals("string", QueryParamExampleUtils.validValue(new StringSchema())); - assertTrue(QueryParamExampleUtils.validValue(new IntegerSchema()).matches("\\d+")); - assertEquals("true", QueryParamExampleUtils.validValue(new BooleanSchema())); + assertEquals("string", QueryParamExampleUtils.validValue(new StringSchema(), null)); + assertTrue(QueryParamExampleUtils.validValue(new IntegerSchema(), null).matches("\\d+")); + assertEquals("true", QueryParamExampleUtils.validValue(new BooleanSchema(), null)); } @Test void validValue_returnsFirstEnumValue_whenSchemaHasEnumAndNoExample() { StringSchema schema = new StringSchema(); schema.setEnum(java.util.List.of("asc", "desc")); - assertEquals("asc", QueryParamExampleUtils.validValue(schema)); + assertEquals("asc", QueryParamExampleUtils.validValue(schema, null)); } @Test @@ -117,21 +119,21 @@ void validValue_prefersExampleOverEnum() { StringSchema schema = new StringSchema(); schema.setEnum(java.util.List.of("asc", "desc")); schema.setExample("desc"); - assertEquals("desc", QueryParamExampleUtils.validValue(schema)); + assertEquals("desc", QueryParamExampleUtils.validValue(schema, null)); } @Test void validValue_returnsIsoDateTime_forDateTimeFormattedString() { StringSchema schema = new StringSchema(); schema.setFormat("date-time"); - assertEquals("2024-01-01T00:00:00Z", QueryParamExampleUtils.validValue(schema)); + assertEquals("2024-01-01T00:00:00Z", QueryParamExampleUtils.validValue(schema, null)); } @Test void invalidValue_setsInvalidMonth_forDateTimeFormattedString() { StringSchema schema = new StringSchema(); schema.setFormat("date-time"); - assertEquals("2024-50-01T00:00:00Z", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("2024-50-01T00:00:00Z", QueryParamExampleUtils.invalidValue(schema, null)); } @Test @@ -139,63 +141,63 @@ void invalidValue_setsInvalidMonth_forDateTimeFormattedStringWithExample() { StringSchema schema = new StringSchema(); schema.setFormat("date-time"); schema.setExample("2025-06-15T10:30:00Z"); - assertEquals("2025-50-15T10:30:00Z", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("2025-50-15T10:30:00Z", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void validValue_returnsRealisticValue_forEmailFormat() { StringSchema schema = new StringSchema(); schema.setFormat("email"); - assertEquals("user@example.com", QueryParamExampleUtils.validValue(schema)); + assertEquals("user@example.com", QueryParamExampleUtils.validValue(schema, null)); } @Test void invalidValue_hasNoAtSign_forEmailFormat() { StringSchema schema = new StringSchema(); schema.setFormat("email"); - assertTrue(!QueryParamExampleUtils.invalidValue(schema).contains("@")); + assertTrue(!QueryParamExampleUtils.invalidValue(schema, null).contains("@")); } @Test void validValue_returnsRealisticValue_forUriFormat() { StringSchema schema = new StringSchema(); schema.setFormat("uri"); - assertEquals("https://example.com", QueryParamExampleUtils.validValue(schema)); + assertEquals("https://example.com", QueryParamExampleUtils.validValue(schema, null)); } @Test void validValue_returnsRealisticValue_forUuidFormat() { StringSchema schema = new StringSchema(); schema.setFormat("uuid"); - assertEquals("3fa85f64-5717-4562-b3fc-2c963f66afa6", QueryParamExampleUtils.validValue(schema)); + assertEquals("3fa85f64-5717-4562-b3fc-2c963f66afa6", QueryParamExampleUtils.validValue(schema, null)); } @Test void invalidValue_isNotUuidShaped_forUuidFormat() { StringSchema schema = new StringSchema(); schema.setFormat("uuid"); - assertEquals("not-a-valid-uuid", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("not-a-valid-uuid", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void validValue_returnsRealisticValue_forIpv4Format() { StringSchema schema = new StringSchema(); schema.setFormat("ipv4"); - assertEquals("192.168.0.1", QueryParamExampleUtils.validValue(schema)); + assertEquals("192.168.0.1", QueryParamExampleUtils.validValue(schema, null)); } @Test void invalidValue_hasOutOfRangeOctets_forIpv4Format() { StringSchema schema = new StringSchema(); schema.setFormat("ipv4"); - assertEquals("999.999.999.999", QueryParamExampleUtils.invalidValue(schema)); + assertEquals("999.999.999.999", QueryParamExampleUtils.invalidValue(schema, null)); } @Test void validValue_returnsRealisticValue_forByteFormat() { StringSchema schema = new StringSchema(); schema.setFormat("byte"); - assertEquals("SGVsbG8gV29ybGQ=", QueryParamExampleUtils.validValue(schema)); + assertEquals("SGVsbG8gV29ybGQ=", QueryParamExampleUtils.validValue(schema, null)); } @Test @@ -203,6 +205,82 @@ void formatAwareness_stillYieldsToExplicitExample() { StringSchema schema = new StringSchema(); schema.setFormat("email"); schema.setExample("custom@example.org"); - assertEquals("custom@example.org", QueryParamExampleUtils.validValue(schema)); + assertEquals("custom@example.org", QueryParamExampleUtils.validValue(schema, null)); + } + + @Test + void validValue_usesConfiguredSuccessfulExample_whenGenericStringHasNoFormat() { + ExampleValues successful = new ExampleValues(); + successful.setString("goodstring"); + assertEquals("goodstring", QueryParamExampleUtils.validValue(new StringSchema(), successful)); + } + + @Test + void invalidValue_usesConfiguredWrongExample_whenGenericStringHasNoMaxLength() { + ExampleValues wrong = new ExampleValues(); + wrong.setString("badstring-configured"); + assertEquals("badstring-configured", QueryParamExampleUtils.invalidValue(new StringSchema(), wrong)); + } + + @Test + void invalidValue_ignoresConfiguredWrongExample_whenMaxLengthDefined() { + StringSchema schema = new StringSchema(); + schema.setMaxLength(3); + ExampleValues wrong = new ExampleValues(); + wrong.setString("badstring-configured"); + assertEquals(4, QueryParamExampleUtils.invalidValue(schema, wrong).length()); + } + + @Test + void validValue_usesConfiguredSuccessfulNumber() { + ExampleValues successful = new ExampleValues(); + successful.setNumber(BigDecimal.valueOf(6)); + assertEquals("6", QueryParamExampleUtils.validValue(new IntegerSchema(), successful)); + } + + @Test + void invalidValue_usesConfiguredWrongNumber_whenNoBoundsDefined() { + ExampleValues wrong = new ExampleValues(); + wrong.setNumber(BigDecimal.valueOf(-6)); + assertEquals("-6", QueryParamExampleUtils.invalidValue(new IntegerSchema(), wrong)); + } + + @Test + void validValue_usesConfiguredSuccessfulBoolean() { + ExampleValues successful = new ExampleValues(); + successful.setBooleanValue(Boolean.FALSE); + assertEquals("false", QueryParamExampleUtils.validValue(new BooleanSchema(), successful)); + } + + @Test + void validValue_usesConfiguredSuccessfulDate() { + ExampleValues successful = new ExampleValues(); + successful.setDate("2020-01-01"); + assertEquals("2020-01-01", QueryParamExampleUtils.validValue(new DateSchema(), successful)); + } + + @Test + void invalidValue_usesConfiguredWrongDateDirectly_whenNoSchemaExample() { + ExampleValues wrong = new ExampleValues(); + wrong.setDate("2020-40-40"); + assertEquals("2020-40-40", QueryParamExampleUtils.invalidValue(new DateSchema(), wrong)); + } + + @Test + void validValue_usesConfiguredSuccessfulDateTime() { + ExampleValues successful = new ExampleValues(); + successful.setDateTime("2020-01-01T23:59:59"); + StringSchema schema = new StringSchema(); + schema.setFormat("date-time"); + assertEquals("2020-01-01T23:59:59", QueryParamExampleUtils.validValue(schema, successful)); + } + + @Test + void invalidValue_usesConfiguredWrongDateTimeDirectly_whenNoSchemaExample() { + ExampleValues wrong = new ExampleValues(); + wrong.setDateTime("2020-40-40T00:00:00"); + StringSchema schema = new StringSchema(); + schema.setFormat("date-time"); + assertEquals("2020-40-40T00:00:00", QueryParamExampleUtils.invalidValue(schema, wrong)); } } From 1a5b92fea7656978372cff1fa59e6b47319eec6f Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Thu, 2 Jul 2026 10:00:55 -0500 Subject: [PATCH 07/11] fix: sonar issues --- .../controller/SoapUIProjectController.java | 5 +- .../openapi2soapui/model/SoapUIProject.java | 210 +++++++++++------- .../service/SoapUIProjectService.java | 8 +- .../service/SoapUIProjectServiceImpl.java | 15 +- .../util/QueryParamExampleUtils.java | 17 +- .../util/QueryParamExampleUtilsTest.java | 2 +- 6 files changed, 156 insertions(+), 101 deletions(-) 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 fa2086a..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,10 +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(), - newSoapUIProject.getReadOnly(), newSoapUIProject.getServerPattern(), newSoapUIProject.getMinimalEndpoints(), - newSoapUIProject.getMicrocksHeaders(), newSoapUIProject.getGenerateOneOfAnyOf(), newSoapUIProject.getExamples()); + 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 55086fa..c508a37 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -637,42 +637,56 @@ private JSONObject iterateProperties(Map properties, RefResolver * @throws JSONException */ @SuppressWarnings("rawtypes") - private Object getPropertyExample(Schema property, RefResolver refResolver) throws JSONException { + private Object getPropertyExample(Schema property, RefResolver refResolver) throws JSONException { Object example = property.getExample(); + if (example != null) { + return example; + } + return getExampleForResolvedType(resolveComposedSchema(property, refResolver), refResolver); + } - if (example == null) { - property = resolveComposedSchema(property, refResolver); - 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 = getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO); - } else if (property instanceof NumberSchema) { - example = getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO); - } else if (property instanceof BooleanSchema) { - example = getConfiguredExample(false, ExampleValues::getBooleanValue, true); - } else if (property instanceof DateSchema) { - example = getConfiguredExample(false, ExampleValues::getDate, 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 if ("date-time".equalsIgnoreCase(stringProperty.getFormat())) { - example = getConfiguredExample(false, ExampleValues::getDateTime, ""); - } else { - example = getConfiguredExample(false, ExampleValues::getString, ""); - } - } else { - example = ""; - } + /** + * Get example for a resolved (non-composed) schema, dispatching by concrete schema type + * @param property resolved Schema + * @param refResolver instance of RefResolver + * @return example value for the schema's type + * @throws JSONException + */ + @SuppressWarnings("rawtypes") + private Object getExampleForResolvedType(Schema property, RefResolver refResolver) throws JSONException { + if (property instanceof ObjectSchema) { + return 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)); + return jsonArray; + } else if (property instanceof IntegerSchema || property instanceof NumberSchema) { + return getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO); + } else if (property instanceof BooleanSchema) { + return getConfiguredExample(false, ExampleValues::getBooleanValue, true); + } else if (property instanceof DateSchema) { + return getConfiguredExample(false, ExampleValues::getDate, new SimpleDateFormat("yyyy-MM-dd").format(new Date())); + } else if (property instanceof StringSchema) { + return getStringExample((StringSchema) property); } - - return example; + return ""; + } + + /** + * 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, ""); } /** @@ -708,29 +722,7 @@ private T getConfiguredExample(boolean wrong, java.util.function.Function allOf = schema.getAllOf(); - if (allOf != null && !allOf.isEmpty()) { - 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); - resolved = merged; - } else if (generateOneOfAnyOf && schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { - resolved = refResolver.resolveSchema((Schema) schema.getOneOf().get(0)); - } else if (generateOneOfAnyOf && schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { - resolved = refResolver.resolveSchema((Schema) schema.getAnyOf().get(0)); - } + Schema resolved = resolveComposedSchemaOnce(schema, refResolver); if (resolved == schema) { return resolved; } @@ -739,6 +731,50 @@ private Schema resolveComposedSchema(Schema schema, RefResolver refResolver) { return schema; } + /** + * 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 * @param object to convert @@ -869,27 +905,35 @@ 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(); - if (readOnly && !"GET".equals(method) && !"OPTIONS".equals(method)) return; - 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 (!minimalEndpoints) { - addQueryParamVariantTestCases(restResource, restMethod, testSuite); - } - }); - } - }); + 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 + * @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); + 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 (!minimalEndpoints) { + addQueryParamVariantTestCases(restResource, restMethod, testSuite); } } @@ -968,7 +1012,7 @@ private void addQueryParamVariantTestCase(RestMethod restMethod, RestRequest def queryParams.forEach(param -> { boolean isTarget = param.getName().equals(targetParam.getName()); - String value = (isTarget && wrong) ? QueryParamExampleUtils.invalidValue(param.getSchema(), examples != null ? examples.getWrong() : null) : getValidQueryParamValue(param); + String value = (isTarget && wrong) ? getInvalidQueryParamValue(param) : getValidQueryParamValue(param); variantRequest.setPropertyValue(param.getName(), value); }); @@ -995,6 +1039,16 @@ private String getValidQueryParamValue(Parameter param) { return QueryParamExampleUtils.validValue(param.getSchema(), examples != null ? examples.getSuccessful() : null); } + /** + * Get Invalid Query Param Value + * Compute a type-aware invalid value for the targeted parameter's schema, used for the "wrong" variant + * @param param OpenAPI Parameter to compute an invalid value for + * @return invalid value as String + */ + private String getInvalidQueryParamValue(Parameter param) { + return QueryParamExampleUtils.invalidValue(param.getSchema(), examples != null ? examples.getWrong() : null); + } + /** * Get content of SoapUI Project File (XML) * @return SoapUI Project file content diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java index b616478..d86daa6 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectService.java @@ -1,17 +1,13 @@ package org.apiaddicts.apitools.openapi2soapui.service; import java.io.IOException; -import java.util.List; -import java.util.Set; import com.eviware.soapui.support.SoapUIException; import io.swagger.v3.oas.models.OpenAPI; import org.apiaddicts.apitools.openapi2soapui.model.SoapUIProject; -import org.apiaddicts.apitools.openapi2soapui.request.OAuth2Profile; -import org.apiaddicts.apitools.openapi2soapui.request.Header; -import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig; +import org.apiaddicts.apitools.openapi2soapui.request.SoapUIProjectRequest; import org.apache.xmlbeans.XmlException; public interface SoapUIProjectService { - SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List oAuth2Profiles, List
headers, Set testCases, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, ExamplesConfig examples) throws IOException, XmlException, SoapUIException; + SoapUIProject createSoapUIProject(SoapUIProjectRequest request, OpenAPI openAPI) throws IOException, XmlException, SoapUIException; } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index f8f4534..1e41b88 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -1,8 +1,6 @@ package org.apiaddicts.apitools.openapi2soapui.service; import java.io.IOException; -import java.util.List; -import java.util.Set; import com.eviware.soapui.support.SoapUIException; import org.apache.xmlbeans.XmlException; @@ -10,17 +8,16 @@ import io.swagger.v3.oas.models.OpenAPI; import org.apiaddicts.apitools.openapi2soapui.model.SoapUIProject; -import org.apiaddicts.apitools.openapi2soapui.request.OAuth2Profile; -import org.apiaddicts.apitools.openapi2soapui.request.Header; -import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig; +import org.apiaddicts.apitools.openapi2soapui.request.SoapUIProjectRequest; @Service public class SoapUIProjectServiceImpl implements SoapUIProjectService { @Override - public SoapUIProject createSoapUIProject(String apiName, OpenAPI openAPI, List credentials, List
headers, - Set testCaseNames, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, ExamplesConfig examples) throws IOException, XmlException, SoapUIException { - return new SoapUIProject(apiName, openAPI, credentials, headers, testCaseNames, readOnly, serverPattern, minimalEndpoints, microcksHeaders, generateOneOfAnyOf, examples); + public SoapUIProject createSoapUIProject(SoapUIProjectRequest request, OpenAPI openAPI) throws IOException, XmlException, SoapUIException { + return new SoapUIProject(request.getApiName(), openAPI, request.getOAuth2Profiles(), request.getHeaders(), + request.getTestCaseNames(), request.getReadOnly(), request.getServerPattern(), request.getMinimalEndpoints(), + request.getMicrocksHeaders(), request.getGenerateOneOfAnyOf(), request.getExamples()); } - + } diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java index 150dc95..be7a104 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtils.java @@ -37,13 +37,23 @@ public static String validValue(Schema schema, ExampleValues successfulExampl if (schema.getExample() != null) return schema.getExample().toString(); if (schema.getEnum() != null && !schema.getEnum().isEmpty()) return schema.getEnum().get(0).toString(); if (schema instanceof StringSchema) return validStringValue((StringSchema) schema, successfulExamples); - if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return configuredOrDefault(successfulExamples, v -> v.getNumber() != null ? v.getNumber().toString() : null, "1"); - if (schema instanceof BooleanSchema) return configuredOrDefault(successfulExamples, v -> v.getBooleanValue() != null ? v.getBooleanValue().toString() : null, "true"); + if (schema instanceof IntegerSchema || schema instanceof NumberSchema) return validNumberValue(successfulExamples); + if (schema instanceof BooleanSchema) return validBooleanValue(successfulExamples); if (schema instanceof ArraySchema) return "[]"; if (schema instanceof ObjectSchema) return "{}"; return "value"; } + private static String validNumberValue(ExampleValues successfulExamples) { + if (successfulExamples == null || successfulExamples.getNumber() == null) return "1"; + return successfulExamples.getNumber().toString(); + } + + private static String validBooleanValue(ExampleValues successfulExamples) { + if (successfulExamples == null || successfulExamples.getBooleanValue() == null) return "true"; + return successfulExamples.getBooleanValue().toString(); + } + public static String invalidValue(Schema schema, ExampleValues wrongExamples) { if (schema == null) return "badvalue"; if (schema instanceof StringSchema) return invalidString((StringSchema) schema, wrongExamples); @@ -68,7 +78,8 @@ private static String validStringValue(StringSchema schema, ExampleValues succes if (URI_FORMAT.equalsIgnoreCase(format) || URL_FORMAT.equalsIgnoreCase(format)) return "https://example.com"; if (UUID_FORMAT.equalsIgnoreCase(format)) return "3fa85f64-5717-4562-b3fc-2c963f66afa6"; if (HOSTNAME_FORMAT.equalsIgnoreCase(format)) return "example.com"; - if (IPV4_FORMAT.equalsIgnoreCase(format)) return "192.168.0.1"; + // RFC 5737 / RFC 3849 reserved documentation ranges, safe to use as literal examples + if (IPV4_FORMAT.equalsIgnoreCase(format)) return "192.0.2.1"; if (IPV6_FORMAT.equalsIgnoreCase(format)) return "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; if (BYTE_FORMAT.equalsIgnoreCase(format)) return "SGVsbG8gV29ybGQ="; return configuredOrDefault(successfulExamples, ExampleValues::getString, "string"); diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java index c6a671d..14873b0 100644 --- a/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/util/QueryParamExampleUtilsTest.java @@ -183,7 +183,7 @@ void invalidValue_isNotUuidShaped_forUuidFormat() { void validValue_returnsRealisticValue_forIpv4Format() { StringSchema schema = new StringSchema(); schema.setFormat("ipv4"); - assertEquals("192.168.0.1", QueryParamExampleUtils.validValue(schema, null)); + assertEquals("192.0.2.1", QueryParamExampleUtils.validValue(schema, null)); } @Test From b0c9542ee3e38b55173d84408ee3c7fe47642aa0 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Thu, 2 Jul 2026 16:41:07 -0500 Subject: [PATCH 08/11] feat: add validateSchema parameter to validate response body against JSON Schema --- CHANGELOG.md | 1 + .../openapi2soapui/constants/Constants.java | 1 + .../openapi2soapui/model/SoapUIProject.java | 317 +++++++++++++++++- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectServiceImpl.java | 2 +- src/main/resources/static/api.yaml | 7 + .../model/ValidateSchemaScriptTest.java | 115 +++++++ 7 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f5741..de04d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,3 +15,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 a Script Assertion to each main test case's request test step that validates the response body against 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. 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 3d3a90a..e1bbe46 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/constants/Constants.java @@ -34,6 +34,7 @@ private Constants() { 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"; 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 c508a37..266972b 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -16,6 +16,7 @@ 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; @@ -30,6 +31,7 @@ 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; @@ -68,6 +70,7 @@ 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; @@ -158,6 +161,11 @@ public class SoapUIProject { * 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; /** * Custom example values from request body, used before falling back to internal defaults */ @@ -189,12 +197,13 @@ public class SoapUIProject { * @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 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, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, ExamplesConfig examples) 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, ExamplesConfig examples) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -212,6 +221,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List 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 anyOf = new ArrayList<>(); + for (Schema alternative : alternatives) { + anyOf.add(buildJsonSchemaDefinition(alternative, refResolver, refsInPath)); + } + definition.put("anyOf", anyOf); + return applyNullable(definition, nullable); + } + Map definition = new LinkedHashMap<>(); + if (schema.getEnum() != null && !schema.getEnum().isEmpty()) { + definition.put("enum", schema.getEnum()); + } + if (schema instanceof ArraySchema) { + definition.put("type", "array"); + definition.put("items", buildJsonSchemaDefinition(((ArraySchema) schema).getItems(), refResolver, refsInPath)); + } else if (schema instanceof IntegerSchema) { + definition.put("type", "integer"); + } else if (schema instanceof NumberSchema) { + definition.put("type", "number"); + } else if (schema instanceof BooleanSchema) { + definition.put("type", "boolean"); + } else if (schema instanceof StringSchema || schema instanceof DateSchema) { + definition.put("type", "string"); + } else { + definition.put("type", "object"); + Map properties = new LinkedHashMap<>(); + if (schema.getProperties() != null) { + Map schemaProperties = schema.getProperties(); + schemaProperties.forEach((propertyName, propertySchema) -> + properties.put(propertyName, buildJsonSchemaDefinition(propertySchema, refResolver, refsInPath))); + } + definition.put("properties", properties); + if (schema.getRequired() != null) { + definition.put("required", schema.getRequired()); + } + if (Boolean.FALSE.equals(schema.getAdditionalProperties())) { + definition.put("additionalProperties", false); + } + } + addCommonConstraints(schema, definition); + return applyNullable(definition, nullable); + } + + /** + * Add Common Constraints + * Copies the JSON Schema keywords that matter most for catching real validation issues (pattern, format, + * string length, numeric bounds, array size/uniqueness) from the OpenAPI Schema into the definition Map, + * when present. Keywords that don't apply to the schema's type are simply absent in the source schema, so + * this can be called unconditionally for every leaf definition + * @param schema source OpenAPI Schema + * @param definition JSON-Schema-shaped Map to enrich in place + */ + @SuppressWarnings("rawtypes") + private void addCommonConstraints(Schema schema, Map definition) { + if (schema.getPattern() != null) { + definition.put("pattern", schema.getPattern()); + } + if (schema.getFormat() != null) { + definition.put("format", schema.getFormat()); + } + if (schema.getMinLength() != null) { + definition.put("minLength", schema.getMinLength()); + } + if (schema.getMaxLength() != null) { + definition.put("maxLength", schema.getMaxLength()); + } + if (schema.getMinimum() != null) { + definition.put("minimum", schema.getMinimum()); + } + if (schema.getMaximum() != null) { + definition.put("maximum", schema.getMaximum()); + } + if (schema.getMinItems() != null) { + definition.put("minItems", schema.getMinItems()); + } + if (schema.getMaxItems() != null) { + definition.put("maxItems", schema.getMaxItems()); + } + if (Boolean.TRUE.equals(schema.getUniqueItems())) { + definition.put("uniqueItems", true); + } + } + + /** + * Apply Nullable + * Marks a schema definition Map as nullable, so the validator accepts a null instance at that node + * @param definition Map produced by buildJsonSchemaDefinition, or any other value (left untouched if not a Map) + * @param nullable whether the original OpenAPI schema declared nullable: true + * @return the same definition, with "nullable": true added when applicable + */ + @SuppressWarnings("unchecked") + private Object applyNullable(Object definition, boolean nullable) { + if (nullable && definition instanceof Map) { + ((Map) definition).put("nullable", true); + } + return definition; + } + + /** + * Resolve Component Schema + * Looks up a $ref directly in components/schemas, independent of RefResolver's resolve-once cache + * @param ref $ref string, e.g. "#/components/schemas/Address" + * @return the referenced Schema, or null if components/schemas or the key itself is not found + */ + @SuppressWarnings("rawtypes") + private Schema resolveComponentSchema(String ref) { + if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) return null; + String[] refParts = ref.split("/"); + return openAPI.getComponents().getSchemas().get(refParts[refParts.length - 1]); + } + + /** + * Build Schema Validation Script + * Builds a self-contained Groovy script (no external libraries required) that parses the response body as + * JSON and recursively validates it against the given JSON Schema definition, failing the assertion with a + * descriptive message when the body is not JSON or does not match the schema + * @param jsonSchemaDefinition Map produced by buildJsonSchemaDefinition + * @return Groovy script source, or null if the schema definition could not be serialized + */ + private String buildSchemaValidationScript(Object jsonSchemaDefinition) { + String schemaJson = mapObjectToJsonString(jsonSchemaDefinition); + if (schemaJson == null) return null; + // Escape backslashes and single quotes so the JSON text survives Groovy's own string-literal escaping + // unchanged (matters for enum values containing backslashes, e.g. Windows-style paths) + String safeSchemaJson = schemaJson.replace("\\", "\\\\").replace("'", "\\'"); + return String.join("\n", + "def json = null", + "try {", + " json = new groovy.json.JsonSlurper().parseText(messageExchange.responseContent)", + "} catch (Exception e) {", + " assert false : \"A JSON response was expected\"", + "}", + "", + "def schema = new groovy.json.JsonSlurper().parseText('''" + safeSchemaJson + "''')", + "", + "def errors = []", + "def validateNode", + "validateNode = { instance, sch, path ->", + " if (sch == null) { return }", + " if (instance == null) {", + " if (!(sch.nullable == true)) { errors << (path + \": expected non-null value\") }", + " return", + " }", + " if (sch.enum != null) {", + " if (!sch.enum.contains(instance)) {", + " errors << (path + \": value is not one of the allowed enum values\")", + " }", + " return", + " }", + " if (sch.anyOf != null) {", + " def matched = false", + " for (candidate in sch.anyOf) {", + " def before = errors.size()", + " validateNode(instance, candidate, path)", + " if (errors.size() == before) { matched = true; break }", + " while (errors.size() > before) { errors.remove(errors.size() - 1) }", + " }", + " if (!matched) { errors << (path + \": does not match any of the expected schemas\") }", + " return", + " }", + " switch (sch.type) {", + " case 'object':", + " if (!(instance instanceof Map)) { errors << (path + \": expected object\"); return }", + " (sch.required ?: []).each { req -> if (!instance.containsKey(req)) { errors << (path + \".\" + req + \": required property missing\") } }", + " (sch.properties ?: [:]).each { propName, propSchema -> if (instance.containsKey(propName)) { validateNode(instance[propName], propSchema, path + \".\" + propName) } }", + " if (sch.additionalProperties == false) {", + " def allowedProps = (sch.properties ?: [:]).keySet()", + " instance.keySet().each { key -> if (!allowedProps.contains(key)) { errors << (path + \".\" + key + \": additional property not allowed\") } }", + " }", + " break", + " case 'array':", + " if (!(instance instanceof List)) { errors << (path + \": expected array\"); return }", + " if (sch.minItems != null && instance.size() < sch.minItems) { errors << (path + \": fewer than minItems \" + sch.minItems) }", + " if (sch.maxItems != null && instance.size() > sch.maxItems) { errors << (path + \": more than maxItems \" + sch.maxItems) }", + " if (sch.uniqueItems == true && instance.size() != (instance as Set).size()) { errors << (path + \": items are not unique\") }", + " instance.eachWithIndex { item, idx -> validateNode(item, sch.items, path + \"[\" + idx + \"]\") }", + " break", + " case 'string':", + " if (!(instance instanceof String)) { errors << (path + \": expected string\"); return }", + " if (sch.pattern && !(instance =~ sch.pattern).find()) { errors << (path + \": does not match pattern '\" + sch.pattern + \"'\") }", + " if (sch.format == 'email' && !(instance ==~ /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/)) { errors << (path + \": does not match format 'email'\") }", + " if (sch.format == 'uuid' && !(instance ==~ /^[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}$/)) { errors << (path + \": does not match format 'uuid'\") }", + " if (sch.format == 'date' && !(instance ==~ /^\\d{4}-\\d{2}-\\d{2}$/)) { errors << (path + \": does not match format 'date'\") }", + " if (sch.format == 'date-time' && !(instance ==~ /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})?$/)) { errors << (path + \": does not match format 'date-time'\") }", + " if (sch.minLength != null && instance.length() < sch.minLength) { errors << (path + \": shorter than minLength \" + sch.minLength) }", + " if (sch.maxLength != null && instance.length() > sch.maxLength) { errors << (path + \": longer than maxLength \" + sch.maxLength) }", + " break", + " case 'integer':", + " if (!(instance instanceof Integer || instance instanceof Long || instance instanceof BigInteger || (instance instanceof BigDecimal && instance.scale() <= 0))) { errors << (path + \": expected integer\"); return }", + " if (sch.minimum != null && new BigDecimal(instance.toString()).compareTo(new BigDecimal(sch.minimum.toString())) < 0) { errors << (path + \": below minimum \" + sch.minimum) }", + " if (sch.maximum != null && new BigDecimal(instance.toString()).compareTo(new BigDecimal(sch.maximum.toString())) > 0) { errors << (path + \": above maximum \" + sch.maximum) }", + " break", + " case 'number':", + " if (!(instance instanceof Number)) { errors << (path + \": expected number\"); return }", + " if (sch.minimum != null && new BigDecimal(instance.toString()).compareTo(new BigDecimal(sch.minimum.toString())) < 0) { errors << (path + \": below minimum \" + sch.minimum) }", + " if (sch.maximum != null && new BigDecimal(instance.toString()).compareTo(new BigDecimal(sch.maximum.toString())) > 0) { errors << (path + \": above maximum \" + sch.maximum) }", + " break", + " case 'boolean':", + " if (!(instance instanceof Boolean)) { errors << (path + \": expected boolean\") }", + " break", + " default:", + " break", + " }", + "}", + "", + "validateNode(json, schema, '$')", + "", + "assert errors.isEmpty() : \"Response body does not match the expected JSON Schema: \" + errors.join('; ')" + ); + } + /** * Add Query Param Variant Test Cases * For each optional query parameter of the Operation bound to this Method, add a valid and an invalid diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index d93d055..debb198 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -53,6 +53,9 @@ public class SoapUIProjectRequest { @JsonProperty("generateOneOfAnyOf") private Boolean generateOneOfAnyOf; + @JsonProperty("validateSchema") + private Boolean validateSchema; + @Valid @JsonProperty("examples") private ExamplesConfig examples; diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 1e41b88..8918141 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -17,7 +17,7 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { public SoapUIProject createSoapUIProject(SoapUIProjectRequest request, OpenAPI openAPI) throws IOException, XmlException, SoapUIException { return new SoapUIProject(request.getApiName(), openAPI, request.getOAuth2Profiles(), request.getHeaders(), request.getTestCaseNames(), request.getReadOnly(), request.getServerPattern(), request.getMinimalEndpoints(), - request.getMicrocksHeaders(), request.getGenerateOneOfAnyOf(), request.getExamples()); + request.getMicrocksHeaders(), request.getGenerateOneOfAnyOf(), request.getValidateSchema(), request.getExamples()); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index d54041f..266a195 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -65,6 +65,8 @@ paths: Additionally, when the **generateOneOfAnyOf** flag is set to true, oneOf/anyOf schemas in the request body are resolved to their first candidate schema when generating the example body (default: unresolved/omitted). allOf schemas are always merged into a single object regardless of this flag. + Additionally, when the **validateSchema** flag is set to true, a Script Assertion is added to each main test case's request test step (the testCaseNames-based cases only) that parses the response body and validates it against the JSON Schema of the operation's first 2xx JSON response, failing the test case if the body is not JSON or does not match the schema. Operations without a 2xx JSON response are skipped (no assertion is added). + The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. requestBody: description: | @@ -80,6 +82,7 @@ paths: - minimalEndpoints: Boolean flag; when false (default), also generates a valid/invalid test case pair per optional query parameter - microcksHeaders: Boolean flag; when true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers - generateOneOfAnyOf: Boolean flag; when true, resolves oneOf/anyOf schemas to their first candidate when generating example bodies. allOf is always merged regardless of this flag + - validateSchema: Boolean flag; when true, adds a Script Assertion to each main test case's request test step that validates the response body against the JSON Schema of the operation's first 2xx JSON response content: aplication/json: schema: @@ -482,6 +485,10 @@ components: type: boolean description: If true, resolves oneOf/anyOf schemas to their first candidate schema when generating the example request body. allOf schemas are always merged into a single object regardless of this flag. example: false + validateSchema: + type: boolean + description: If true, adds a Script Assertion to each main test case's request test step that parses the response body and validates it against the JSON Schema of the operation's first 2xx JSON response. Operations without a 2xx JSON response are skipped. Does not affect the optional-query-parameter variant test cases. + example: false serverPattern: type: string description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java new file mode 100644 index 0000000..a70a10f --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java @@ -0,0 +1,115 @@ +package org.apiaddicts.apitools.openapi2soapui.model; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import groovy.lang.GroovyShell; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; + +/** + * Throwaway end-to-end verification for the validateSchema Groovy script: builds a real SoapUIProject, + * extracts the generated Script Assertion text from the actual serialized XML, and executes it with + * GroovyShell against valid and invalid sample response bodies + */ +class ValidateSchemaScriptTest { + + private static final String SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /users:", + " get:", + " operationId: getUsers", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id, name]", + " properties:", + " id:", + " type: integer", + " name:", + " type: string", + " minLength: 2", + " email:", + " type: string", + " format: email", + " nullable: true", + " tags:", + " type: array", + " items:", + " type: string", + " uniqueItems: true" + ); + + @Test + void generatedScriptIsValidGroovyAndValidatesCorrectly() throws Exception { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(SPEC, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, null); + String xml = soapUIProject.getFileContent(); + + assertTrue(xml.contains("Script Assertion"), "XML should contain the Script Assertion"); + assertTrue(xml.contains("JsonSlurper"), "XML should contain the embedded validator script"); + + int start = xml.indexOf("def json = null"); + String marker = "errors.join('; ')"; + int end = xml.indexOf(marker); + if (end == -1) { + marker = "errors.join('; ')"; + end = xml.indexOf(marker); + } + assertTrue(start >= 0 && end >= 0, "Should find the script bounds in the generated XML"); + String script = xml.substring(start, end + marker.length()) + .replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + + Map validResponse = new HashMap<>(); + validResponse.put("responseContent", "{\"id\":1,\"name\":\"Al\",\"email\":null,\"tags\":[\"a\",\"b\"]}"); + GroovyShell validShell = new GroovyShell(); + validShell.setVariable("messageExchange", validResponse); + validShell.evaluate(script); + + Map invalidResponse = new HashMap<>(); + invalidResponse.put("responseContent", "{\"id\":1,\"name\":\"A\",\"email\":\"not-an-email\",\"tags\":[\"a\",\"a\"]}"); + GroovyShell invalidShell = new GroovyShell(); + invalidShell.setVariable("messageExchange", invalidResponse); + AssertionError thrown = assertThrows(AssertionError.class, () -> invalidShell.evaluate(script)); + assertTrue(thrown.getMessage().contains("minLength"), "Should report the minLength violation: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("email"), "Should report the email format violation: " + thrown.getMessage()); + assertTrue(thrown.getMessage().contains("unique"), "Should report the uniqueItems violation: " + thrown.getMessage()); + + Map missingRequiredResponse = new HashMap<>(); + missingRequiredResponse.put("responseContent", "{\"name\":\"Ale\"}"); + GroovyShell missingShell = new GroovyShell(); + missingShell.setVariable("messageExchange", missingRequiredResponse); + AssertionError missingThrown = assertThrows(AssertionError.class, () -> missingShell.evaluate(script)); + assertTrue(missingThrown.getMessage().contains("required property missing"), missingThrown.getMessage()); + + Map notJsonResponse = new HashMap<>(); + notJsonResponse.put("responseContent", "not json"); + GroovyShell notJsonShell = new GroovyShell(); + notJsonShell.setVariable("messageExchange", notJsonResponse); + AssertionError notJsonThrown = assertThrows(AssertionError.class, () -> notJsonShell.evaluate(script)); + assertTrue(notJsonThrown.getMessage().contains("A JSON response was expected"), notJsonThrown.getMessage()); + + assertFalse(script.trim().isEmpty()); + } +} From 898561aa973b983ad3daab2be7a44c96ddeb0631 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Fri, 3 Jul 2026 08:36:39 -0500 Subject: [PATCH 09/11] feat: add isInline flag for request body Project Property references --- CHANGELOG.md | 1 + .../openapi2soapui/model/SoapUIProject.java | 104 +++++++++-- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectServiceImpl.java | 3 +- src/main/resources/static/api.yaml | 7 + .../openapi2soapui/model/IsInlineTest.java | 163 ++++++++++++++++++ .../model/ValidateSchemaScriptTest.java | 2 +- 7 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/model/IsInlineTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index de04d57..c815716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 a Script Assertion to each main test case's request test step that validates the response body against 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). 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 266972b..575ffc4 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -176,6 +176,26 @@ public class SoapUIProject { * 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; + /** + * 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<>(); /** * SoapUIProject constructor @@ -198,12 +218,13 @@ public class SoapUIProject { * @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 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 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, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, Boolean validateSchema, ExamplesConfig examples) 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 isInline, ExamplesConfig examples) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -222,6 +243,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List(); Object example; Schema schema = resolveComposedSchema(refResolver.resolveSchema(mediaType.getSchema()), refResolver); if (mediaType.getExample() != null) { @@ -609,7 +634,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; } @@ -618,16 +643,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); @@ -636,51 +663,53 @@ 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) { - return example; + return registerBodyValue(path, example); } - return getExampleForResolvedType(resolveComposedSchema(property, refResolver), refResolver); + 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) throws JSONException { + private Object getExampleForResolvedType(Schema property, RefResolver refResolver, String path) throws JSONException { if (property instanceof ObjectSchema) { - return iterateProperties(((ObjectSchema) property).getProperties(), refResolver); + 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)); + jsonArray.put(getPropertyExample(items, refResolver, path + "_item")); return jsonArray; } else if (property instanceof IntegerSchema || property instanceof NumberSchema) { - return getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO); + return registerBodyValue(path, getConfiguredExample(false, ExampleValues::getNumber, java.math.BigDecimal.ZERO)); } else if (property instanceof BooleanSchema) { - return getConfiguredExample(false, ExampleValues::getBooleanValue, true); + return registerBodyValue(path, getConfiguredExample(false, ExampleValues::getBooleanValue, true)); } else if (property instanceof DateSchema) { - return getConfiguredExample(false, ExampleValues::getDate, new SimpleDateFormat("yyyy-MM-dd").format(new Date())); + return registerBodyValue(path, getConfiguredExample(false, ExampleValues::getDate, new SimpleDateFormat("yyyy-MM-dd").format(new Date()))); } else if (property instanceof StringSchema) { - return getStringExample((StringSchema) property); + return registerBodyValue(path, getStringExample((StringSchema) property)); } - return ""; + return registerBodyValue(path, ""); } /** @@ -809,6 +838,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 diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index debb198..4c53774 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -56,6 +56,9 @@ public class SoapUIProjectRequest { @JsonProperty("validateSchema") private Boolean validateSchema; + @JsonProperty("isInline") + private Boolean isInline; + @Valid @JsonProperty("examples") private ExamplesConfig examples; diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index 8918141..6c556a7 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -17,7 +17,8 @@ public class SoapUIProjectServiceImpl implements SoapUIProjectService { public SoapUIProject createSoapUIProject(SoapUIProjectRequest request, OpenAPI openAPI) throws IOException, XmlException, SoapUIException { return new SoapUIProject(request.getApiName(), openAPI, request.getOAuth2Profiles(), request.getHeaders(), request.getTestCaseNames(), request.getReadOnly(), request.getServerPattern(), request.getMinimalEndpoints(), - request.getMicrocksHeaders(), request.getGenerateOneOfAnyOf(), request.getValidateSchema(), request.getExamples()); + request.getMicrocksHeaders(), request.getGenerateOneOfAnyOf(), request.getValidateSchema(), + request.getIsInline(), request.getExamples()); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index 266a195..6814152 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -67,6 +67,8 @@ paths: Additionally, when the **validateSchema** flag is set to true, a Script Assertion is added to each main test case's request test step (the testCaseNames-based cases only) that parses the response body and validates it against the JSON Schema of the operation's first 2xx JSON response, failing the test case if the body is not JSON or does not match the schema. Operations without a 2xx JSON response are skipped (no assertion is added). + Additionally, when the **isInline** flag is set to false (the default), every generated JSON request-body example value is stored as a SoapUI Project Property and referenced from the body via a `${#Project#...}` token, instead of being embedded literally; set to true to embed literal values directly in the body as before. + The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. requestBody: description: | @@ -83,6 +85,7 @@ paths: - microcksHeaders: Boolean flag; when true, adds an X-Microcks-Response-Name header to each request, in addition to any custom headers - generateOneOfAnyOf: Boolean flag; when true, resolves oneOf/anyOf schemas to their first candidate when generating example bodies. allOf is always merged regardless of this flag - validateSchema: Boolean flag; when true, adds a Script Assertion to each main test case's request test step that validates the response body against the JSON Schema of the operation's first 2xx JSON response + - isInline: Boolean flag; when false (default), JSON request-body example values are generated as SoapUI Project Properties referenced via `${#Project#...}` tokens instead of literal values; when true, literal values are embedded directly in the body content: aplication/json: schema: @@ -489,6 +492,10 @@ components: type: boolean description: If true, adds a Script Assertion to each main test case's request test step that parses the response body and validates it against the JSON Schema of the operation's first 2xx JSON response. Operations without a 2xx JSON response are skipped. Does not affect the optional-query-parameter variant test cases. example: false + isInline: + type: boolean + description: If false (default), JSON request body example values are generated as SoapUI Project Properties and referenced from the body via a "${#Project#...}" token. If true, literal example values are embedded directly in the body instead. + example: false serverPattern: type: string description: Pattern to select the OpenAPI server used as endpoint. Wrap the substring with % (e.g. %dev%). If no server matches, the first server is used. diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/IsInlineTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/IsInlineTest.java new file mode 100644 index 0000000..c0394f4 --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/IsInlineTest.java @@ -0,0 +1,163 @@ +package org.apiaddicts.apitools.openapi2soapui.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import org.apiaddicts.apitools.openapi2soapui.request.ExampleValues; +import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig; + +class IsInlineTest { + + private static final String SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " post:", + " operationId: createItem", + " requestBody:", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " name:", + " type: string", + " age:", + " type: integer", + " responses:", + " '200':", + " description: OK" + ); + + private OpenAPI parseSpec() { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(SPEC, null, null); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + return result.getOpenAPI(); + } + + private ExamplesConfig examplesConfig() { + ExampleValues successful = new ExampleValues(); + successful.setString("Ada"); + successful.setNumber(new BigDecimal(42)); + ExamplesConfig examples = new ExamplesConfig(); + examples.setSuccessful(successful); + return examples; + } + + private String decodeAndCompact(String xml) { + String decoded = xml.replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + return decoded.replaceAll("\\s+", ""); + } + + @Test + void isInlineTrue_embedsLiteralValues() throws Exception { + OpenAPI openAPI = parseSpec(); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, false, true, examplesConfig()); + String compact = decodeAndCompact(soapUIProject.getFileContent()); + + assertTrue(compact.contains("\"name\":\"Ada\""), "Body should contain the literal string value: " + compact); + assertTrue(compact.contains("\"age\":42"), "Body should contain the literal number value: " + compact); + assertFalse(compact.contains("${#Project#"), "Body should not contain Project Property tokens: " + compact); + } + + @Test + void isInlineFalse_usesProjectPropertyTokens() throws Exception { + OpenAPI openAPI = parseSpec(); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, false, false, examplesConfig()); + String compact = decodeAndCompact(soapUIProject.getFileContent()); + + assertTrue(compact.contains("\"name\":\"${#Project#body1_name}\""), "String field should be a quoted Project Property token: " + compact); + assertTrue(compact.contains("\"age\":${#Project#body1_age}"), "Number field should be an unquoted Project Property token: " + compact); + assertEquals("Ada", soapUIProject.getProject().getPropertyValue("body1_name")); + assertEquals("42", soapUIProject.getProject().getPropertyValue("body1_age")); + } + + private static final String NESTED_MULTI_OP_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " post:", + " operationId: createItem", + " requestBody:", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " name:", + " type: string", + " address:", + " type: object", + " properties:", + " street:", + " type: string", + " tags:", + " type: array", + " items:", + " type: object", + " properties:", + " label:", + " type: string", + " responses:", + " '200':", + " description: OK", + " /orders:", + " post:", + " operationId: createOrder", + " requestBody:", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " name:", + " type: string", + " responses:", + " '200':", + " description: OK" + ); + + @Test + void isInlineFalse_handlesNestedObjectsArraysAndMultipleOperationsWithoutKeyCollisions() throws Exception { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(NESTED_MULTI_OP_SPEC, null, null); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + OpenAPI openAPI = result.getOpenAPI(); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, false, false, null); + String compact = decodeAndCompact(soapUIProject.getFileContent()); + + // /items body (generated first -> counter 1): top-level, nested object and array-of-object leaves + assertTrue(compact.contains("\"name\":\"${#Project#body1_name}\""), "Top-level string field: " + compact); + assertTrue(compact.contains("\"street\":\"${#Project#body1_address_street}\""), "Nested object field: " + compact); + assertTrue(compact.contains("\"label\":\"${#Project#body1_tags_item_label}\""), "Array-of-object item field: " + compact); + + // /orders body (generated second -> counter 2): same field name "name" as /items must not collide + assertTrue(compact.contains("\"name\":\"${#Project#body2_name}\""), "Second operation's field must use a distinct counter prefix: " + compact); + + assertEquals("", soapUIProject.getProject().getPropertyValue("body1_name")); + assertEquals("", soapUIProject.getProject().getPropertyValue("body1_address_street")); + assertEquals("", soapUIProject.getProject().getPropertyValue("body1_tags_item_label")); + assertEquals("", soapUIProject.getProject().getPropertyValue("body2_name")); + } +} diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java index a70a10f..e2462e3 100644 --- a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java @@ -62,7 +62,7 @@ void generatedScriptIsValidGroovyAndValidatesCorrectly() throws Exception { assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, - false, null, true, false, false, true, null); + false, null, true, false, false, true, false, null); String xml = soapUIProject.getFileContent(); assertTrue(xml.contains("Script Assertion"), "XML should contain the Script Assertion"); From eace046d7bbadabe9a76bf96d08cd1ea6ad70b9f Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Mon, 6 Jul 2026 17:13:56 -0500 Subject: [PATCH 10/11] feat: add schemaIsInline parameter to reference response schema --- CHANGELOG.md | 3 +- .../openapi2soapui/model/SoapUIProject.java | 35 +- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectServiceImpl.java | 2 +- src/main/resources/static/api.yaml | 7 + .../openapi2soapui/model/IsInlineTest.java | 6 +- .../model/SchemaIsInlineEdgeCasesTest.java | 339 ++++++++++++++++++ .../model/SchemaIsInlineTest.java | 124 +++++++ .../model/ValidateSchemaScriptTest.java | 2 +- 9 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineEdgeCasesTest.java create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c815716..3c52842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,5 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 a Script Assertion to each main test case's request test step that validates the response body against 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. +- `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). 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 575ffc4..98b2dcd 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -166,6 +166,13 @@ public class SoapUIProject { * 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; /** * Custom example values from request body, used before falling back to internal defaults */ @@ -188,6 +195,11 @@ public class SoapUIProject { * 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/ @@ -218,13 +230,14 @@ public class SoapUIProject { * @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 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, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, Boolean validateSchema, Boolean isInline, ExamplesConfig examples) 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, ExamplesConfig examples) throws IOException, XmlException, SoapUIException { this.apiName = apiName; this.openAPI = openAPI; this.headers = headers; @@ -243,6 +256,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List counter 1): top-level, nested object and array-of-object leaves diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineEdgeCasesTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineEdgeCasesTest.java new file mode 100644 index 0000000..f23be23 --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineEdgeCasesTest.java @@ -0,0 +1,339 @@ +package org.apiaddicts.apitools.openapi2soapui.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +import groovy.lang.GroovyShell; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; + +class SchemaIsInlineEdgeCasesTest { + + private static final String SIMPLE_ITEMS_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer" + ); + + private static final String ENUM_WITH_BACKSLASH_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id, path]", + " properties:", + " id:", + " type: integer", + " path:", + " type: string", + " enum: ['C:\\Users\\a', 'C:\\Users\\b']" + ); + + private OpenAPI parseSpec(String yaml) { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + return result.getOpenAPI(); + } + + private String decode(String s) { + return s.replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + } + + private List extractAllScripts(String xml) { + List scripts = new ArrayList<>(); + int searchFrom = 0; + while (true) { + int start = xml.indexOf("def json = null", searchFrom); + if (start < 0) break; + String marker = "errors.join('; ')"; + int end = xml.indexOf(marker, start); + if (end == -1) { + marker = "errors.join('; ')"; + end = xml.indexOf(marker, start); + } + assertTrue(end >= 0, "Should find the script end marker"); + scripts.add(decode(xml.substring(start, end + marker.length()))); + searchFrom = end + marker.length(); + } + return scripts; + } + + private String extractPropertyKey(String script) { + Matcher matcher = Pattern.compile("\\$\\{#Project#(schema\\d+)\\}").matcher(script); + assertTrue(matcher.find(), "Script should reference a schema Project Property: " + script); + return matcher.group(1); + } + + private Object fakeContext(String expandedValue) { + return new Object() { + @SuppressWarnings("unused") + public String expand(String token) { + return expandedValue; + } + }; + } + + private boolean evaluatesSuccessfully(String script, String storedSchema, String responseBody) { + Map response = new HashMap<>(); + response.put("responseContent", responseBody); + GroovyShell shell = new GroovyShell(); + shell.setVariable("messageExchange", response); + shell.setVariable("context", fakeContext(storedSchema)); + try { + shell.evaluate(script); + return true; + } catch (AssertionError e) { + return false; + } + } + + @Test + void validateSchemaFalse_neverGeneratesAssertionRegardlessOfSchemaIsInline() throws Exception { + OpenAPI openAPI = parseSpec(SIMPLE_ITEMS_SPEC); + + for (boolean schemaIsInline : new boolean[]{false, true}) { + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, false, schemaIsInline, false, null); + String xml = soapUIProject.getFileContent(); + assertFalse(xml.contains("Script Assertion"), "No assertion should be added when validateSchema is false (schemaIsInline=" + schemaIsInline + ")"); + assertNull(soapUIProject.getProject().getPropertyValue("schema1"), "No schema property should be registered (schemaIsInline=" + schemaIsInline + ")"); + } + } + + @Test + void operationWithNo2xxJsonResponse_skipsAssertionWithoutCrashing() throws Exception { + OpenAPI openAPI = parseSpec(String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /nothing:", + " get:", + " operationId: getNothing", + " responses:", + " '204':", + " description: No Content" + )); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, null); + String xml = soapUIProject.getFileContent(); + + assertFalse(xml.contains("Script Assertion"), "No assertion should be added for a 204 response: " + xml); + assertNull(soapUIProject.getProject().getPropertyValue("schema1")); + } + + @Test + void schemaIsInlineNull_defaultsToExternalReference() throws Exception { + OpenAPI openAPI = parseSpec(SIMPLE_ITEMS_SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, null, false, null); + String xml = soapUIProject.getFileContent(); + List scripts = extractAllScripts(xml); + + assertEquals(1, scripts.size()); + assertTrue(scripts.get(0).contains("context.expand("), "Omitted schemaIsInline should default to the external/false behavior: " + scripts.get(0)); + assertNotNull(soapUIProject.getProject().getPropertyValue("schema1")); + } + + @Test + void multipleTestCaseNames_generateIndependentlyValidatingProperties() throws Exception { + OpenAPI openAPI = parseSpec(SIMPLE_ITEMS_SPEC); + Set testCaseNames = new LinkedHashSet<>(Arrays.asList("Success", "Alt")); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, testCaseNames, + false, null, true, false, false, true, false, false, null); + String xml = soapUIProject.getFileContent(); + List scripts = extractAllScripts(xml); + + assertEquals(2, scripts.size(), "Should generate one assertion per test case name"); + String schema1 = soapUIProject.getProject().getPropertyValue("schema1"); + String schema2 = soapUIProject.getProject().getPropertyValue("schema2"); + assertTrue(schema1 != null && schema1.equals(schema2), "Both test cases share the same operation schema"); + + for (String script : scripts) { + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + assertTrue(evaluatesSuccessfully(script, stored, "{\"id\":1}"), "Valid body should pass: " + script); + assertFalse(evaluatesSuccessfully(script, stored, "{}"), "Missing required id should fail: " + script); + } + } + + @Test + void multipleOperations_eachAssertionValidatesOnlyItsOwnSchema() throws Exception { + OpenAPI openAPI = parseSpec(String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer", + " /orders:", + " get:", + " operationId: getOrders", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [total]", + " properties:", + " total:", + " type: number" + )); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, null); + String xml = soapUIProject.getFileContent(); + List scripts = extractAllScripts(xml); + assertEquals(2, scripts.size()); + + String itemsBody = "{\"id\":1}"; + String ordersBody = "{\"total\":5}"; + int passesForItems = 0; + int passesForOrders = 0; + for (String script : scripts) { + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + boolean itemsOk = evaluatesSuccessfully(script, stored, itemsBody); + boolean ordersOk = evaluatesSuccessfully(script, stored, ordersBody); + assertTrue(itemsOk ^ ordersOk, "Each assertion must validate exactly one of the two operation shapes: " + script); + if (itemsOk) passesForItems++; else passesForOrders++; + } + assertEquals(1, passesForItems, "Exactly one assertion should be wired to the items schema"); + assertEquals(1, passesForOrders, "Exactly one assertion should be wired to the orders schema"); + } + + @Test + void isInlineAndSchemaIsInlineBothFalse_coexistWithoutKeyCollision() throws Exception { + OpenAPI openAPI = parseSpec(String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " post:", + " operationId: createItem", + " requestBody:", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " name:", + " type: string", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer" + )); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, null); + String xml = soapUIProject.getFileContent(); + + assertTrue(xml.contains("${#Project#body1_name}"), "Body token should be present: " + xml); + List scripts = extractAllScripts(xml); + assertEquals(1, scripts.size()); + assertEquals("schema1", extractPropertyKey(scripts.get(0)), "Schema property key must not collide with body property keys"); + + assertEquals("", soapUIProject.getProject().getPropertyValue("body1_name")); + String storedSchema = soapUIProject.getProject().getPropertyValue("schema1"); + assertTrue(storedSchema.contains("\"id\"")); + + assertTrue(evaluatesSuccessfully(scripts.get(0), storedSchema, "{\"id\":7}")); + assertFalse(evaluatesSuccessfully(scripts.get(0), storedSchema, "{}")); + } + + @Test + void schemaWithBackslashInEnum_validatesCorrectlyEmbeddedLiterally() throws Exception { + OpenAPI openAPI = parseSpec(ENUM_WITH_BACKSLASH_SPEC); + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, true, false, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + + assertTrue(evaluatesSuccessfully(script, null, "{\"id\":1,\"path\":\"C:\\\\Users\\\\a\"}")); + assertFalse(evaluatesSuccessfully(script, null, "{\"id\":1,\"path\":\"C:\\\\Users\\\\c\"}")); + } + + @Test + void schemaWithBackslashInEnum_validatesCorrectlyViaProjectProperty() throws Exception { + OpenAPI openAPI = parseSpec(ENUM_WITH_BACKSLASH_SPEC); + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + + assertTrue(stored.contains("C:\\\\Users\\\\a"), "Stored property should contain the JSON-escaped backslash path: " + stored); + assertTrue(evaluatesSuccessfully(script, stored, "{\"id\":1,\"path\":\"C:\\\\Users\\\\a\"}")); + assertFalse(evaluatesSuccessfully(script, stored, "{\"id\":1,\"path\":\"C:\\\\Users\\\\c\"}")); + } +} diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineTest.java new file mode 100644 index 0000000..0daeb66 --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaIsInlineTest.java @@ -0,0 +1,124 @@ +package org.apiaddicts.apitools.openapi2soapui.model; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import groovy.lang.GroovyShell; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; + +class SchemaIsInlineTest { + + private static final String SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /users:", + " get:", + " operationId: getUsers", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer", + " name:", + " type: string" + ); + + private OpenAPI parseSpec() { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(SPEC, null, null); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + return result.getOpenAPI(); + } + + private String extractScript(String xml) { + int start = xml.indexOf("def json = null"); + String marker = "errors.join('; ')"; + int end = xml.indexOf(marker); + if (end == -1) { + marker = "errors.join('; ')"; + end = xml.indexOf(marker); + } + assertTrue(start >= 0 && end >= 0, "Should find the script bounds in the generated XML"); + return xml.substring(start, end + marker.length()) + .replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + } + + private Object fakeContext(String expandedValue) { + return new Object() { + @SuppressWarnings("unused") + public String expand(String token) { + return expandedValue; + } + }; + } + + @Test + void schemaIsInlineFalse_referencesProjectPropertyAndValidatesAtRuntime() throws Exception { + OpenAPI openAPI = parseSpec(); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, null); + String xml = soapUIProject.getFileContent(); + String script = extractScript(xml); + + assertTrue(script.contains("context.expand('${#Project#schema1}')"), "Script should reference the schema via a Project Property token: " + script); + assertFalse(script.contains("parseText('''"), "Script should not embed the schema literally: " + script); + + String storedSchema = soapUIProject.getProject().getPropertyValue("schema1"); + assertTrue(storedSchema != null && storedSchema.contains("\"id\""), "Schema JSON should be stored as a Project Property: " + storedSchema); + + Map validResponse = new HashMap<>(); + validResponse.put("responseContent", "{\"id\":1,\"name\":\"Al\"}"); + GroovyShell validShell = new GroovyShell(); + validShell.setVariable("messageExchange", validResponse); + validShell.setVariable("context", fakeContext(storedSchema)); + validShell.evaluate(script); + + Map invalidResponse = new HashMap<>(); + invalidResponse.put("responseContent", "{\"name\":\"Al\"}"); + GroovyShell invalidShell = new GroovyShell(); + invalidShell.setVariable("messageExchange", invalidResponse); + invalidShell.setVariable("context", fakeContext(storedSchema)); + AssertionError thrown = assertThrows(AssertionError.class, () -> invalidShell.evaluate(script)); + assertTrue(thrown.getMessage().contains("required property missing"), thrown.getMessage()); + } + + @Test + void schemaIsInlineTrue_embedsSchemaLiterally() throws Exception { + OpenAPI openAPI = parseSpec(); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, true, false, null); + String xml = soapUIProject.getFileContent(); + String script = extractScript(xml); + + assertTrue(script.contains("parseText('''"), "Script should embed the schema literally: " + script); + assertFalse(script.contains("context.expand("), "Script should not reference a Project Property: " + script); + assertNull(soapUIProject.getProject().getPropertyValue("schema1"), "No schema Project Property should be registered"); + + Map validResponse = new HashMap<>(); + validResponse.put("responseContent", "{\"id\":1,\"name\":\"Al\"}"); + GroovyShell validShell = new GroovyShell(); + validShell.setVariable("messageExchange", validResponse); + validShell.evaluate(script); + } +} diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java index e2462e3..cd77427 100644 --- a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/ValidateSchemaScriptTest.java @@ -62,7 +62,7 @@ void generatedScriptIsValidGroovyAndValidatesCorrectly() throws Exception { assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, - false, null, true, false, false, true, false, null); + false, null, true, false, false, true, true, false, null); String xml = soapUIProject.getFileContent(); assertTrue(xml.contains("Script Assertion"), "XML should contain the Script Assertion"); From 905007b07b705d9df133b657ee4827d450ab67b3 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Mon, 6 Jul 2026 20:16:42 -0500 Subject: [PATCH 11/11] feat: add schemaPrettyPrint parameter to format schema output --- CHANGELOG.md | 1 + .../openapi2soapui/model/SoapUIProject.java | 38 +- .../request/SoapUIProjectRequest.java | 3 + .../service/SoapUIProjectServiceImpl.java | 2 +- src/main/resources/static/api.yaml | 7 + .../model/SchemaPrettyPrintEdgeCasesTest.java | 338 ++++++++++++++++++ .../model/SchemaPrettyPrintTest.java | 196 ++++++++++ 7 files changed, 579 insertions(+), 6 deletions(-) create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintEdgeCasesTest.java create mode 100644 src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c52842..f3cda1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java index 98b2dcd..b0aca90 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/model/SoapUIProject.java @@ -173,6 +173,12 @@ public class SoapUIProject { * 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 */ @@ -209,6 +215,14 @@ public class SoapUIProject { */ 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 @@ -232,12 +246,13 @@ public class SoapUIProject { * @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, Boolean readOnly, String serverPattern, Boolean minimalEndpoints, Boolean microcksHeaders, Boolean generateOneOfAnyOf, Boolean validateSchema, Boolean schemaIsInline, Boolean isInline, ExamplesConfig examples) 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; @@ -258,6 +273,7 @@ public SoapUIProject(String apiName, OpenAPI openAPI, List allOf, RefResolver refResolver) { } /** - * Convert Object or JSONObject to JSON String + * 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); } @@ -1245,7 +1273,7 @@ private Schema resolveComponentSchema(String ref) { * @return Groovy script source, or null if the schema definition could not be serialized */ private String buildSchemaValidationScript(Object jsonSchemaDefinition) { - String schemaJson = mapObjectToJsonString(jsonSchemaDefinition); + String schemaJson = mapObjectToJsonString(jsonSchemaDefinition, schemaPrettyPrint); if (schemaJson == null) return null; String schemaSource; if (schemaIsInline) { diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java index 4068c22..2fc5e13 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/request/SoapUIProjectRequest.java @@ -59,6 +59,9 @@ public class SoapUIProjectRequest { @JsonProperty("schemaIsInline") private Boolean schemaIsInline; + @JsonProperty("schemaPrettyPrint") + private Boolean schemaPrettyPrint; + @JsonProperty("isInline") private Boolean isInline; diff --git a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java index b0ad045..0aa396f 100644 --- a/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java +++ b/src/main/java/org/apiaddicts/apitools/openapi2soapui/service/SoapUIProjectServiceImpl.java @@ -18,7 +18,7 @@ public SoapUIProject createSoapUIProject(SoapUIProjectRequest request, OpenAPI o return new SoapUIProject(request.getApiName(), openAPI, request.getOAuth2Profiles(), request.getHeaders(), request.getTestCaseNames(), request.getReadOnly(), request.getServerPattern(), request.getMinimalEndpoints(), request.getMicrocksHeaders(), request.getGenerateOneOfAnyOf(), request.getValidateSchema(), - request.getSchemaIsInline(), request.getIsInline(), request.getExamples()); + request.getSchemaIsInline(), request.getIsInline(), request.getSchemaPrettyPrint(), request.getExamples()); } } diff --git a/src/main/resources/static/api.yaml b/src/main/resources/static/api.yaml index eef5a68..fe41530 100644 --- a/src/main/resources/static/api.yaml +++ b/src/main/resources/static/api.yaml @@ -69,6 +69,8 @@ paths: Additionally, when the **validateSchema** flag is true and the **schemaIsInline** flag is set to false (the default), the JSON Schema used by that assertion is stored as a SoapUI Project Property and read from the script at runtime via a `context.expand('${#Project#...}')` call, instead of being embedded literally in the script; set to true to embed the schema directly in the script as before. Has no effect when validateSchema is false. + Additionally, when the **validateSchema** flag is true and the **schemaPrettyPrint** flag is set to true (the default), the JSON Schema used by that assertion is pretty-printed (indented); set to false to serialize it compactly instead, with no extra whitespace. Has no effect when validateSchema is false. + Additionally, when the **isInline** flag is set to false (the default), every generated JSON request-body example value is stored as a SoapUI Project Property and referenced from the body via a `${#Project#...}` token, instead of being embedded literally; set to true to embed literal values directly in the body as before. The response is the content of the SoapUI project in XML format to save as file and import into the SoapUI application. @@ -88,6 +90,7 @@ paths: - generateOneOfAnyOf: Boolean flag; when true, resolves oneOf/anyOf schemas to their first candidate when generating example bodies. allOf is always merged regardless of this flag - validateSchema: Boolean flag; when true, adds a Script Assertion to each main test case's request test step that validates the response body against the JSON Schema of the operation's first 2xx JSON response - schemaIsInline: Boolean flag; only relevant when validateSchema is true. When false (default), the response JSON Schema is generated as a SoapUI Project Property referenced via a `context.expand('${#Project#...}')` call instead of a literal value; when true, the schema is embedded directly in the assertion script + - schemaPrettyPrint: Boolean flag; only relevant when validateSchema is true. When true (default), the response JSON Schema is pretty-printed (indented); when false, it is serialized compactly with no extra whitespace - isInline: Boolean flag; when false (default), JSON request-body example values are generated as SoapUI Project Properties referenced via `${#Project#...}` tokens instead of literal values; when true, literal values are embedded directly in the body content: aplication/json: @@ -499,6 +502,10 @@ components: type: boolean description: Only relevant when validateSchema is true. If false (default), the response JSON Schema used by the validateSchema assertion is generated as a SoapUI Project Property and read from the script at runtime via a "context.expand('${#Project#...}')" call. If true, the schema is embedded directly as a literal in the assertion script instead. example: false + schemaPrettyPrint: + type: boolean + description: Only relevant when validateSchema is true. If true (default), the response JSON Schema used by the validateSchema assertion is pretty-printed (indented). If false, it is serialized compactly with no extra whitespace. + example: true isInline: type: boolean description: If false (default), JSON request body example values are generated as SoapUI Project Properties and referenced from the body via a "${#Project#...}" token. If true, literal example values are embedded directly in the body instead. diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintEdgeCasesTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintEdgeCasesTest.java new file mode 100644 index 0000000..277e356 --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintEdgeCasesTest.java @@ -0,0 +1,338 @@ +package org.apiaddicts.apitools.openapi2soapui.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +import groovy.lang.GroovyShell; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; + +class SchemaPrettyPrintEdgeCasesTest { + + private static final String SIMPLE_ITEMS_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer" + ); + + private static final String ENUM_WITH_BACKSLASH_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id, path]", + " properties:", + " id:", + " type: integer", + " path:", + " type: string", + " enum: ['C:\\Users\\a', 'C:\\Users\\b']" + ); + + private static final String TWO_OPERATIONS_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " get:", + " operationId: getItems", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer", + " /orders:", + " get:", + " operationId: getOrders", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [total]", + " properties:", + " total:", + " type: number" + ); + + private static final String COMPLEX_NESTED_SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /orders:", + " get:", + " operationId: getOrders", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id, items]", + " properties:", + " id:", + " type: integer", + " items:", + " type: array", + " items:", + " type: object", + " required: [sku]", + " properties:", + " sku:", + " type: string", + " payment:", + " oneOf:", + " - type: object", + " required: [cardNumber]", + " properties:", + " cardNumber:", + " type: string", + " - type: object", + " required: [accountId]", + " properties:", + " accountId:", + " type: string" + ); + + private OpenAPI parseSpec(String yaml) { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + return result.getOpenAPI(); + } + + private String decode(String s) { + return s.replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + } + + private List extractAllScripts(String xml) { + List scripts = new ArrayList<>(); + int searchFrom = 0; + while (true) { + int start = xml.indexOf("def json = null", searchFrom); + if (start < 0) break; + String marker = "errors.join('; ')"; + int end = xml.indexOf(marker, start); + if (end == -1) { + marker = "errors.join('; ')"; + end = xml.indexOf(marker, start); + } + assertTrue(end >= 0, "Should find the script end marker"); + scripts.add(decode(xml.substring(start, end + marker.length()))); + searchFrom = end + marker.length(); + } + return scripts; + } + + private String extractPropertyKey(String script) { + Matcher matcher = Pattern.compile("\\$\\{#Project#(schema\\d+)\\}").matcher(script); + assertTrue(matcher.find(), "Script should reference a schema Project Property: " + script); + return matcher.group(1); + } + + private Object fakeContext(String expandedValue) { + return new Object() { + @SuppressWarnings("unused") + public String expand(String token) { + return expandedValue; + } + }; + } + + private boolean evaluatesSuccessfully(String script, String storedSchema, String responseBody) { + Map response = new HashMap<>(); + response.put("responseContent", responseBody); + GroovyShell shell = new GroovyShell(); + shell.setVariable("messageExchange", response); + shell.setVariable("context", fakeContext(storedSchema)); + try { + shell.evaluate(script); + return true; + } catch (AssertionError e) { + return false; + } + } + + @Test + void schemaPrettyPrintFalse_withSchemaIsInline_embedsCompactJsonLiterallyAndValidates() throws Exception { + OpenAPI openAPI = parseSpec(SIMPLE_ITEMS_SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, true, false, false, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + + assertTrue(script.contains("parseText('''"), "Schema should be embedded literally: " + script); + int schemaStart = script.indexOf("parseText('''") + "parseText('''".length(); + int schemaEnd = script.indexOf("''')", schemaStart); + String embeddedSchema = script.substring(schemaStart, schemaEnd); + assertFalse(embeddedSchema.contains("\n"), "Embedded schema should be compact (single line): " + embeddedSchema); + + assertTrue(evaluatesSuccessfully(script, null, "{\"id\":1}"), "Valid body should pass"); + assertFalse(evaluatesSuccessfully(script, null, "{}"), "Missing required id should fail"); + } + + @Test + void schemaPrettyPrintTrue_withSchemaIsInline_embedsMultilineJsonLiterallyAndValidates() throws Exception { + OpenAPI openAPI = parseSpec(SIMPLE_ITEMS_SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, true, false, true, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + + assertTrue(script.contains("parseText('''"), "Schema should be embedded literally: " + script); + int schemaStart = script.indexOf("parseText('''") + "parseText('''".length(); + int schemaEnd = script.indexOf("''')", schemaStart); + String embeddedSchema = script.substring(schemaStart, schemaEnd); + assertTrue(embeddedSchema.contains("\n"), "Embedded schema should be pretty-printed (multi-line) even inside the Groovy triple-quoted string: " + embeddedSchema); + + assertTrue(evaluatesSuccessfully(script, null, "{\"id\":1}"), "Valid body should pass"); + assertFalse(evaluatesSuccessfully(script, null, "{}"), "Missing required id should fail"); + } + + @Test + void schemaWithBackslashInEnum_validatesCorrectlyEmbeddedLiterally_whenCompact() throws Exception { + OpenAPI openAPI = parseSpec(ENUM_WITH_BACKSLASH_SPEC); + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, true, false, false, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + + assertTrue(evaluatesSuccessfully(script, null, "{\"id\":1,\"path\":\"C:\\\\Users\\\\a\"}"), "Enum value with backslashes should validate: " + script); + assertFalse(evaluatesSuccessfully(script, null, "{\"id\":1,\"path\":\"C:\\\\Users\\\\c\"}"), "Value outside the enum should fail: " + script); + } + + @Test + void schemaWithBackslashInEnum_validatesCorrectlyViaProjectProperty_whenCompact() throws Exception { + OpenAPI openAPI = parseSpec(ENUM_WITH_BACKSLASH_SPEC); + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, false, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + + assertFalse(stored.contains("\n"), "Compact schema should be a single line: " + stored); + assertTrue(stored.contains("C:\\\\Users\\\\a"), "Stored property should contain the JSON-escaped backslash path: " + stored); + assertTrue(evaluatesSuccessfully(script, stored, "{\"id\":1,\"path\":\"C:\\\\Users\\\\a\"}")); + assertFalse(evaluatesSuccessfully(script, stored, "{\"id\":1,\"path\":\"C:\\\\Users\\\\c\"}")); + } + + @Test + void multipleOperations_eachSchemaRespectsCompactFlagIndependently() throws Exception { + OpenAPI openAPI = parseSpec(TWO_OPERATIONS_SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, false, null); + List scripts = extractAllScripts(soapUIProject.getFileContent()); + assertEquals(2, scripts.size()); + + String schema1 = soapUIProject.getProject().getPropertyValue("schema1"); + String schema2 = soapUIProject.getProject().getPropertyValue("schema2"); + assertFalse(schema1.contains("\n"), "First operation's schema should be compact: " + schema1); + assertFalse(schema2.contains("\n"), "Second operation's schema should be compact too, not just the first one: " + schema2); + + String itemsBody = "{\"id\":1}"; + String ordersBody = "{\"total\":5}"; + int passesForItems = 0; + int passesForOrders = 0; + for (String script : scripts) { + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + boolean itemsOk = evaluatesSuccessfully(script, stored, itemsBody); + boolean ordersOk = evaluatesSuccessfully(script, stored, ordersBody); + assertTrue(itemsOk ^ ordersOk, "Each assertion must validate exactly one of the two operation shapes: " + script); + if (itemsOk) passesForItems++; else passesForOrders++; + } + assertEquals(1, passesForItems); + assertEquals(1, passesForOrders); + } + + @Test + void schemaPrettyPrintFalse_hasNoEffectWhenValidateSchemaIsFalse() throws Exception { + OpenAPI openAPI = parseSpec(SIMPLE_ITEMS_SPEC); + + for (boolean schemaIsInline : new boolean[]{false, true}) { + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, false, schemaIsInline, false, false, null); + String xml = soapUIProject.getFileContent(); + assertFalse(xml.contains("Script Assertion"), "No assertion should be added when validateSchema is false (schemaIsInline=" + schemaIsInline + ")"); + } + } + + @Test + void complexNestedSchemaWithArrayAndOneOf_compactStillValidatesCorrectly() throws Exception { + OpenAPI openAPI = parseSpec(COMPLEX_NESTED_SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, false, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + + assertFalse(stored.contains("\n"), "Complex nested schema should still be compact: " + stored); + assertTrue(evaluatesSuccessfully(script, stored, "{\"id\":1,\"items\":[{\"sku\":\"A1\",\"payment\":{\"cardNumber\":\"4111\"}}]}"), "Body matching the first oneOf branch should pass"); + assertTrue(evaluatesSuccessfully(script, stored, "{\"id\":1,\"items\":[{\"sku\":\"A1\",\"payment\":{\"accountId\":\"AC1\"}}]}"), "Body matching the second oneOf branch should pass"); + assertFalse(evaluatesSuccessfully(script, stored, "{\"id\":1,\"items\":[{\"sku\":\"A1\",\"payment\":{\"other\":\"x\"}}]}"), "Body matching neither oneOf branch should fail"); + } + + @Test + void complexNestedSchemaWithArrayAndOneOf_prettyStillValidatesCorrectly() throws Exception { + OpenAPI openAPI = parseSpec(COMPLEX_NESTED_SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, true, null); + String script = extractAllScripts(soapUIProject.getFileContent()).get(0); + String stored = soapUIProject.getProject().getPropertyValue(extractPropertyKey(script)); + + assertTrue(stored.contains("\n"), "Complex nested schema should be pretty-printed: " + stored); + assertTrue(evaluatesSuccessfully(script, stored, "{\"id\":1,\"items\":[{\"sku\":\"A1\",\"payment\":{\"cardNumber\":\"4111\"}}]}"), "Body matching the first oneOf branch should pass"); + assertFalse(evaluatesSuccessfully(script, stored, "{\"id\":1,\"items\":[{\"sku\":\"A1\",\"payment\":{\"other\":\"x\"}}]}"), "Body matching neither oneOf branch should fail"); + } +} diff --git a/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintTest.java b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintTest.java new file mode 100644 index 0000000..1d7b6e1 --- /dev/null +++ b/src/test/java/org/apiaddicts/apitools/openapi2soapui/model/SchemaPrettyPrintTest.java @@ -0,0 +1,196 @@ +package org.apiaddicts.apitools.openapi2soapui.model; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import groovy.lang.GroovyShell; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import org.apiaddicts.apitools.openapi2soapui.request.ExampleValues; +import org.apiaddicts.apitools.openapi2soapui.request.ExamplesConfig; + +class SchemaPrettyPrintTest { + + private static final String SPEC = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /users:", + " get:", + " operationId: getUsers", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer", + " name:", + " type: string" + ); + + private static final String SPEC_WITH_BODY = String.join("\n", + "openapi: 3.0.0", + "info:", + " title: Test", + " version: '1.0'", + "paths:", + " /items:", + " post:", + " operationId: createItem", + " requestBody:", + " content:", + " application/json:", + " schema:", + " type: object", + " properties:", + " name:", + " type: string", + " age:", + " type: integer", + " responses:", + " '200':", + " description: OK", + " content:", + " application/json:", + " schema:", + " type: object", + " required: [id]", + " properties:", + " id:", + " type: integer", + " name:", + " type: string" + ); + + private OpenAPI parseSpec(String spec) { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(spec, null, null); + assertTrue(result.getMessages().isEmpty(), "Spec should parse without errors: " + result.getMessages()); + return result.getOpenAPI(); + } + + private ExamplesConfig examplesConfig() { + ExampleValues successful = new ExampleValues(); + successful.setString("Ada"); + successful.setNumber(new BigDecimal(42)); + ExamplesConfig examples = new ExamplesConfig(); + examples.setSuccessful(successful); + return examples; + } + + private String extractScript(String xml) { + int start = xml.indexOf("def json = null"); + String marker = "errors.join('; ')"; + int end = xml.indexOf(marker); + if (end == -1) { + marker = "errors.join('; ')"; + end = xml.indexOf(marker); + } + assertTrue(start >= 0 && end >= 0, "Should find the script bounds in the generated XML"); + return xml.substring(start, end + marker.length()) + .replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + } + + private Object fakeContext(String expandedValue) { + return new Object() { + @SuppressWarnings("unused") + public String expand(String token) { + return expandedValue; + } + }; + } + + private void assertSchemaValidatesResponses(String script, String storedSchema) { + Map validResponse = new HashMap<>(); + validResponse.put("responseContent", "{\"id\":1,\"name\":\"Al\"}"); + GroovyShell validShell = new GroovyShell(); + validShell.setVariable("messageExchange", validResponse); + validShell.setVariable("context", fakeContext(storedSchema)); + validShell.evaluate(script); + + Map invalidResponse = new HashMap<>(); + invalidResponse.put("responseContent", "{\"name\":\"Al\"}"); + GroovyShell invalidShell = new GroovyShell(); + invalidShell.setVariable("messageExchange", invalidResponse); + invalidShell.setVariable("context", fakeContext(storedSchema)); + AssertionError thrown = assertThrows(AssertionError.class, () -> invalidShell.evaluate(script)); + assertTrue(thrown.getMessage().contains("required property missing"), thrown.getMessage()); + } + + @Test + void schemaPrettyPrintTrue_producesIndentedSchemaJson() throws Exception { + OpenAPI openAPI = parseSpec(SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, true, null); + String storedSchema = soapUIProject.getProject().getPropertyValue("schema1"); + + assertTrue(storedSchema.contains("\n"), "Pretty-printed schema should span multiple lines: " + storedSchema); + assertTrue(storedSchema.contains("\"type\" : \"object\""), "Pretty-printed schema should have spaced colons: " + storedSchema); + + assertSchemaValidatesResponses(extractScript(soapUIProject.getFileContent()), storedSchema); + } + + @Test + void schemaPrettyPrintFalse_producesCompactSchemaJson() throws Exception { + OpenAPI openAPI = parseSpec(SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, false, null); + String storedSchema = soapUIProject.getProject().getPropertyValue("schema1"); + + assertFalse(storedSchema.contains("\n"), "Compact schema should be a single line: " + storedSchema); + assertTrue(storedSchema.contains("\"type\":\"object\""), "Compact schema should have no whitespace around colons: " + storedSchema); + + assertSchemaValidatesResponses(extractScript(soapUIProject.getFileContent()), storedSchema); + } + + @Test + void schemaPrettyPrintUnset_defaultsToTrueViaLegacyConstructor() throws Exception { + OpenAPI openAPI = parseSpec(SPEC); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, false, null); + String storedSchema = soapUIProject.getProject().getPropertyValue("schema1"); + + assertTrue(storedSchema.contains("\n"), "Default (unset) schemaPrettyPrint should keep the schema pretty-printed: " + storedSchema); + } + + @Test + void schemaPrettyPrintFalse_doesNotAffectRequestBodyExampleFormatting() throws Exception { + OpenAPI openAPI = parseSpec(SPEC_WITH_BODY); + + SoapUIProject soapUIProject = new SoapUIProject("TestApi", openAPI, null, null, null, + false, null, true, false, false, true, false, true, false, examplesConfig()); + + String storedSchema = soapUIProject.getProject().getPropertyValue("schema1"); + assertFalse(storedSchema.contains("\n"), "Schema should still be compact: " + storedSchema); + + String decoded = soapUIProject.getFileContent() + .replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'") + .replace("&", "&"); + int bodyStart = decoded.indexOf("\"age\": 42"); + assertTrue(bodyStart >= 0, "Request body example should still be embedded and pretty-printed: " + decoded); + int bodyEnd = decoded.indexOf("\"Ada\"", bodyStart); + assertTrue(bodyEnd >= 0, "Request body example should still be embedded and pretty-printed: " + decoded); + String bodySnippet = decoded.substring(bodyStart, bodyEnd); + assertTrue(bodySnippet.contains("\n"), "Request body example should remain pretty-printed even when schemaPrettyPrint is false: " + bodySnippet); + } +}