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