From 93f4a1a86c142dc53b568ad63f22fbc9c3465c3b Mon Sep 17 00:00:00 2001 From: Kuniyuki Hayashi Date: Mon, 21 Sep 2026 03:34:55 +0900 Subject: [PATCH 1/3] Recognize OpenAPI 3.2.x version strings, routed through the 3.1 model path OpenAPI 3.2 builds on the 3.1 data model, so 3.2 documents are now accepted wherever the 3.1-family code path is selected: - OpenAPIDeserializer.parseRoot accepts "3.2" (loose prefix, matching the existing convention for "3.0"/"3.1") and sets the openapi31 flag - the dotted-form warning check gains "3.2." so a bare "3.2" is accepted but reported as not a valid version field, same as bare "3.0"/"3.1" - OpenAPIV3Parser.resolve and OpenAPIDereferencer31.canDereference route 3.2 documents through the 3.1 dereferencer - ResolverCache treats 3.2 as openapi31 so the 3.1 (JSON Schema 2020-12) Jackson mapper is selected for external-ref deserialization. This is load-bearing only for direct OpenAPIResolver callers (public API): the internal resolve() path routes 3.1/3.2 through OpenAPIDereferencer31 and never constructs a ResolverCache for them - canDereference gains a null guard on getOpenapi() The openapi31 flag's meaning is widened from "3.1" to "3.1-family or later". This is a stopgap: swagger-core's SpecVersion enum already exists (V30/V31 today); once it gains a V32 value (swagger-core PR #5254/#5255 direction), this flag should be replaced with that. 3.2-only members (query operation, additionalOperations, $self, in: querystring parameters) are reported as "attribute ... is unexpected" / "is not of type" validation messages but are not reproduced in the parsed model, so resolved output can differ from a fully 3.2-aware parser. README notes the experimental handling. Tests: OAI32DeserializationTest covers 3.2.0/3.2.1 parsing, unexpected-attribute recording for 3.2-only fields, resolveFully actually resolving refs, routing through the 3.1 dereferencer via components.pathItems, bare "3.2" acceptance-with-warning, loose-prefix "3.20.0" acceptance-with-warning (a prefix-match artifact, not true 3.x support), 3.3.0 genuinely rejected (contrast with the 3.20.0 case), and malformed "3.2." -- which parses without a warning, an inherited quirk of the pre-existing convention that this change extends rather than fixes, asserted explicitly so it doesn't silently drift if the 3.0/3.1 behavior changes later. Reviewed by Codex and Fable (3 rounds each: initial, post-rebase, and this follow-up); this revision addresses all rounds' findings. --- README.md | 4 +- .../io/swagger/v3/parser/OpenAPIV3Parser.java | 2 +- .../io/swagger/v3/parser/ResolverCache.java | 2 +- .../reference/OpenAPIDereferencer31.java | 2 +- .../v3/parser/util/OpenAPIDeserializer.java | 6 +- .../parser/test/OAI32DeserializationTest.java | 227 ++++++++++++++++++ 6 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java diff --git a/README.md b/README.md index 2da9afb47f..b16ccda07c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ SwaggerParseResult result = new OpenAPIParser().readContents("./path/to/swagger. the Swagger/OpenAPI 2.0 document will be first converted into a comparable OpenAPI 3.0 one. -You can also directly use `OpenAPIV3Parser` which only handles OpenAPI 3.0 documents, and provides a convenience method to get directly the parsed `OpenAPI object: +You can also directly use `OpenAPIV3Parser` which handles OpenAPI 3.x documents, and provides a convenience method to get directly the parsed `OpenAPI` object. + +Note: OpenAPI 3.2 documents are currently parsed through the OpenAPI 3.1 model. Elements introduced in 3.2 (e.g. the `query` operation, `additionalOperations`, `$self`, `in: querystring` parameters) are reported as validation messages but are not reproduced in the parsed model, so resolved output can differ from what a fully 3.2-aware parser would produce. ```java import io.swagger.v3.parser.OpenAPIV3Parser; diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java index 5ca34cefff..03fd681633 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/OpenAPIV3Parser.java @@ -213,7 +213,7 @@ private SwaggerParseResult resolve(SwaggerParseResult result, List auths, String par } public ResolverCache(OpenAPI openApi, List auths, String parentFileLocation, Set resolveValidationMessages, ParseOptions parseOptions) { - this.openapi31 = openApi != null && openApi.getOpenapi() != null && openApi.getOpenapi().startsWith("3.1"); + this.openapi31 = openApi != null && openApi.getOpenapi() != null && (openApi.getOpenapi().startsWith("3.1") || openApi.getOpenapi().startsWith("3.2")); this.openApi = openApi; this.auths = auths; this.rootPath = parentFileLocation; diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java index 9ac62a9873..94f97bef9d 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java @@ -15,7 +15,7 @@ public class OpenAPIDereferencer31 implements OpenAPIDereferencer { public boolean canDereference(DereferencerContext context) { - if (context.openApi != null && context.openApi.getOpenapi().startsWith("3.1")) { + if (context.openApi != null && context.openApi.getOpenapi() != null && (context.openApi.getOpenapi().startsWith("3.1") || context.openApi.getOpenapi().startsWith("3.2"))) { return true; } return false; diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java index 421822dd3b..553b7d3a12 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java @@ -331,13 +331,13 @@ public OpenAPI parseRoot(JsonNode node, ParseResult result, String path) { String value = getString("openapi", rootNode, true, location, result); // we don't even try if the version isn't there - if (value == null || (!value.startsWith("3.0") && !value.startsWith("3.1"))) { + if (value == null || (!value.startsWith("3.0") && !value.startsWith("3.1") && !value.startsWith("3.2"))) { return null; - } else if (value.startsWith("3.1")) { + } else if (value.startsWith("3.1") || value.startsWith("3.2")) { result.openapi31(true); openAPI.setSpecVersion(SpecVersion.V31); } - if (!value.startsWith("3.0.") && !value.startsWith("3.1.")){ + if (!value.startsWith("3.0.") && !value.startsWith("3.1.") && !value.startsWith("3.2.")){ result.warning(location, "The provided definition does not specify a valid version field"); } openAPI.setOpenapi(value); diff --git a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java new file mode 100644 index 0000000000..b24b38c020 --- /dev/null +++ b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java @@ -0,0 +1,227 @@ +package io.swagger.v3.parser.test; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +public class OAI32DeserializationTest { + + @Test(description = "OpenAPI 3.2.0 version string is recognized and parsed") + public void testBasicOAS320() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2.0"); + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getGet()); + // 3.2 shares the 3.1 data model code path + assertTrue(result.isOpenapi31()); + } + + @Test(description = "OpenAPI 3.2.1 version string is recognized and parsed") + public void testBasicOAS321() { + String json = "{\n" + + " \"openapi\": \"3.2.1\",\n" + + " \"info\": {\n" + + " \"title\": \"Swagger Petstore\",\n" + + " \"version\": \"1.0.0\"\n" + + " },\n" + + " \"paths\": {}\n" + + "}"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(json, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2.1"); + assertTrue(result.isOpenapi31()); + } + + @Test(description = "3.2-specific fields do not crash parsing and are reported as unexpected attributes") + public void testOAS32SpecificFieldsRecorded() { + String yaml = "openapi: 3.2.0\n" + + "$self: https://example.com/api/openapi.yaml\n" + + "jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " query:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNotNull(openAPI.getPaths().get("/pets").getGet()); + // 3.2-only members are not modeled yet: they surface as validation messages + assertTrue(result.getMessages().contains("attribute $self is unexpected")); + assertTrue(result.getMessages().contains("attribute paths.'/pets'.query is unexpected")); + // 3.1-family fields continue to work + assertEquals(openAPI.getJsonSchemaDialect(), "https://json-schema.org/draft/2020-12/schema"); + } + + @Test(description = "3.2 document parses with resolveFully option without crashing") + public void testOAS32ResolveFully() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: '#/components/schemas/Pet'\n" + + "components:\n" + + " schemas:\n" + + " Pet:\n" + + " type: object\n" + + " properties:\n" + + " name:\n" + + " type: string\n"; + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getOpenapi(), "3.2.0"); + // the $ref is actually resolved through the 3.1-family dereference path + Schema schema = openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").getSchema(); + assertNull(schema.get$ref()); + assertNotNull(schema.getProperties().get("name")); + } + + @Test(description = "3.2 resolve routes through the 3.1-family dereferencer: components.pathItems refs resolve") + public void testOAS32ResolveRoutesTo31Dereferencer() { + // components.pathItems is a 3.1+ feature the legacy (3.0) resolver cannot resolve: + // ResolverCache has no pattern for #/components/pathItems, so under the wrong + // routing this assertion fails (the pathItem stays an unresolved $ref with no ops) + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " $ref: '#/components/pathItems/PetOps'\n" + + "components:\n" + + " pathItems:\n" + + " PetOps:\n" + + " get:\n" + + " operationId: listPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNotNull(openAPI.getPaths().get("/pets").getGet()); + assertEquals(openAPI.getPaths().get("/pets").getGet().getOperationId(), "listPets"); + } + + @Test(description = "3.2 'in: querystring' parameter is dropped with a validation message (current best-effort)") + public void testOAS32QuerystringParamReported() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - name: q\n" + + " in: querystring\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + // the parameter is not representable yet: it is dropped and reported + assertTrue(openAPI.getPaths().get("/pets").getGet().getParameters() == null + || openAPI.getPaths().get("/pets").getGet().getParameters().isEmpty()); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("in is not of type `[query|header|path|cookie]`"))); + } + + @Test(description = "bare 3.2 (no patch version) is accepted per upstream's loose convention, with a warning") + public void testBareVersionString() { + // mirrors upstream's handling of bare "3.0"/"3.1" (see OpenAPIV3ParserTest#testIssue1780): + // accepted and routed to the 3.1-family model, but reported as not a valid version field + String yaml = "openapi: '3.2'\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2"); + assertTrue(result.isOpenapi31()); + assertTrue(result.getMessages().contains("The provided definition does not specify a valid version field")); + } + + @Test(description = "loose prefix: 3.20.x is accepted because it starts with \"3.2\" (prefix match, not true 3.x support -- 3.3.0 is rejected), and warned since it has no 3.2. dotted form") + public void testVersionPrefixBoundary() { + String yaml = "openapi: 3.20.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertTrue(result.isOpenapi31()); + assertTrue(result.getMessages().contains("The provided definition does not specify a valid version field")); + } + + @Test(description = "malformed version 3.2. (trailing dot, no patch) still parses; no warning fires, matching the pre-existing 3.0./3.1. quirk this change extends rather than fixes") + public void testMalformedVersionString() { + // "3.2." satisfies startsWith("3.2."), so the dotted-form warning check does not + // fire for it, the same way "3.0." and "3.1." alone would not warn either. This is + // an inherited quirk of the pre-existing convention, not something new to 3.2 -- + // asserted explicitly here so a future change to the 3.0/3.1 behavior doesn't + // silently leave 3.2 inconsistent with it. + String yaml = "openapi: '3.2.'\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI()); + assertEquals(result.getOpenAPI().getOpenapi(), "3.2."); + assertTrue(result.isOpenapi31()); + assertFalse(result.getMessages().contains("The provided definition does not specify a valid version field")); + } + + @Test(description = "3.3.0 is genuinely rejected (unlike 3.20.0, which is only accepted by prefix-match accident)") + public void testUnrecognizedMinorVersionRejected() { + String yaml = "openapi: 3.3.0\n" + + "info:\n" + + " title: Swagger Petstore\n" + + " version: 1.0.0\n" + + "paths: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNull(result.getOpenAPI()); + } +} From 35397200342e740e477b344c04b28a3fc1a157fc Mon Sep 17 00:00:00 2001 From: Kuniyuki Hayashi Date: Tue, 22 Sep 2026 02:13:55 +0900 Subject: [PATCH 2/3] Bind OpenAPI 3.2 fields in OpenAPIDeserializer and reference resolution - Bind 3.2-only fields into the model instead of reporting them as unexpected: $self, PathItem.query/additionalOperations, Parameter in=querystring and style=cookie, Components.mediaTypes, MediaType $ref/itemSchema/prefixEncoding/itemEncoding, Encoding nested encoding/prefixEncoding/itemEncoding, Server.name, Tag summary/parent/kind, ApiResponse.summary, Example dataValue/serializedValue, SecurityScheme deprecated/ oauth2MetadataUrl, OAuthFlows.deviceAuthorization + OAuthFlow.deviceAuthorizationUrl, XML.nodeType, Discriminator.defaultMapping - Add SwaggerParseResult.openapi32 and a version-aware spec key set so 3.0/3.1 documents keep their historical behavior and error messages (invalid 'in' message still lists only query|header|path|cookie) - Semantic validation per the 3.2 spec: querystring requires content and forbids schema/style/explode/allowReserved/allowEmptyValue; querystring+query coexistence checked within a parameter list and across path-item/operation parameters, including local components.parameters refs; duplicate fixed method names in additionalOperations are warned - Resolution: propagate openapi32 into fragment parsing, use Json32 mapper for deepcopy/ids cache on 3.2 documents, traverse PathItem.query/additionalOperations, Components.mediaTypes, MediaType.$ref/itemSchema/prefixEncoding/itemEncoding and nested Encoding fields - Tests: 31 cases in OAI32DeserializationTest covering binding, JSON/YAML round-trips, callbacks/webhooks/components.pathItems, invalid inputs and resolveFully; swagger-core 2.2.56-SNAPSHOT (local 3f1fd6030) --- .../core/models/SwaggerParseResult.java | 18 +- .../v3/parser/reference/IdsTraverser.java | 15 +- .../parser/reference/OpenAPI31Traverser.java | 61 +- .../reference/OpenAPIDereferencer31.java | 4 +- .../v3/parser/reference/ReferenceVisitor.java | 10 + .../v3/parser/util/OpenAPIDeserializer.java | 432 +++++++++- .../parser/test/OAI32DeserializationTest.java | 736 +++++++++++++++++- .../io/swagger/parser/OpenAPIParserTest.java | 3 +- pom.xml | 2 +- 9 files changed, 1230 insertions(+), 51 deletions(-) diff --git a/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java b/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java index 53b6e3b15f..9558c189e3 100644 --- a/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java +++ b/modules/swagger-parser-core/src/main/java/io/swagger/v3/parser/core/models/SwaggerParseResult.java @@ -11,6 +11,7 @@ public class SwaggerParseResult { private List messages = null; private OpenAPI openAPI; private boolean openapi31; + private boolean openapi32; public SwaggerParseResult messages(List messages) { this.messages = messages; @@ -68,16 +69,29 @@ public boolean isOpenapi31() { return this.openapi31; } + public void setOpenapi32(boolean openapi32) { + this.openapi32 = openapi32; + } + + public SwaggerParseResult openapi32(boolean openapi32) { + this.openapi32 = openapi32; + return this; + } + + public boolean isOpenapi32() { + return this.openapi32; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SwaggerParseResult result = (SwaggerParseResult) o; - return openapi31 == result.openapi31 && Objects.equals(messages, result.messages) && Objects.equals(openAPI, result.openAPI); + return openapi31 == result.openapi31 && openapi32 == result.openapi32 && Objects.equals(messages, result.messages) && Objects.equals(openAPI, result.openAPI); } @Override public int hashCode() { - return Objects.hash(messages, openAPI, openapi31); + return Objects.hash(messages, openAPI, openapi31, openapi32); } } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java index 3b1e2a4797..16fcf1533a 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Json32; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; @@ -520,7 +521,8 @@ public Schema traverseSchema(Schema schema, Visitor visitor, List inheri resolvedURI = ReferenceUtils.resolve(urlWithoutHash, resolvedURI); resolvedURI = ReferenceUtils.toBaseURI(resolvedURI); } - context.getIdsCache().put(resolvedURI, Json31.pretty(schema)); + context.getIdsCache().put(resolvedURI, + isOpenapi32() ? Json32.pretty(schema) : Json31.pretty(schema)); } catch (Exception e) { // } @@ -639,9 +641,18 @@ public Schema traverseSchema(Schema schema, Visitor visitor, List inheri } + private boolean isOpenapi32() { + return context != null && context.getSwaggerParseResult() != null + && context.getSwaggerParseResult().isOpenapi32(); + } + + private com.fasterxml.jackson.databind.ObjectMapper modelMapper() { + return isOpenapi32() ? Json32.mapper() : Json31.mapper(); + } + public T deepcopy(T entity, Class clazz) { try { - return (T)Json31.mapper().readValue(Json31.mapper().writeValueAsString(entity), clazz); + return (T)modelMapper().readValue(modelMapper().writeValueAsString(entity), clazz); } catch (JsonProcessingException e) { throw new RuntimeException(e); } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java index ddcb08ba69..5cbe30c865 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Json32; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; @@ -70,7 +71,8 @@ public OpenAPI traverse(OpenAPI openAPI, Visitor visitor) throws Exception { public T deserializeFragment(JsonNode node, Class expectedType, String uri, String fragment, Set validationMessages) { String sanitizedFragment = fragment == null ? "" : fragment; - OpenAPIDeserializer.ParseResult parseResult = new OpenAPIDeserializer.ParseResult().openapi31(true); + OpenAPIDeserializer.ParseResult parseResult = new OpenAPIDeserializer.ParseResult() + .openapi31(true).openapi32(isOpenapi32()); T result = null; if (expectedType.equals(Schema.class)) { result = (T) deserializer.getSchema((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); @@ -92,6 +94,8 @@ public T deserializeFragment(JsonNode node, Class expectedType, String ur result = (T) deserializer.getSecurityScheme((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); }else if (expectedType.equals(PathItem.class)) { result = (T) deserializer.getPathItem((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); + }else if (expectedType.equals(MediaType.class)) { + result = (T) deserializer.getMediaType((ObjectNode) node, sanitizedFragment.replace("/", "."), parseResult); } parseResult.getMessages().forEach((m) -> { validationMessages.add(m + " (" + uri + ")"); @@ -170,6 +174,7 @@ public Components traverseComponents(Components components, ReferenceVisitor vis traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); traverseMap(resolved.getLinks(), visitor, this::traverseLink); traverseMap(resolved.getResponses(), visitor, this::traverseResponse); + traverseMap(resolved.getMediaTypes(), visitor, this::traverseMediaType); traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(components, resolved); @@ -430,6 +435,12 @@ public PathItem traversePathItem(PathItem pathItem, ReferenceVisitor visitor) { if (resolvedOperation != null) { resolved.setTrace(resolvedOperation); } + Operation queryOp = resolved.getQuery(); + resolvedOperation = traverseOperation(queryOp, visitor); + if (resolvedOperation != null) { + resolved.setQuery(resolvedOperation); + } + traverseMap(resolved.getAdditionalOperations(), visitor, this::traverseOperation); if (resolved.getParameters() != null) { for (int i = 0; i < resolved.getParameters().size(); i++) { @@ -462,6 +473,15 @@ public PathItem traversePathItem(PathItem pathItem, ReferenceVisitor visitor) { if (pathItem.getSummary() != null) { resolved.summary(pathItem.getSummary()); } + if (pathItem.getQuery() != null) { + resolved.setQuery(pathItem.getQuery()); + } + if (pathItem.getAdditionalOperations() != null) { + if (resolved.getAdditionalOperations() == null) { + resolved.setAdditionalOperations(new LinkedHashMap<>()); + } + resolved.getAdditionalOperations().putAll(pathItem.getAdditionalOperations()); + } // TODO additional undefined merge if other props are defined visitedMap.put(pathItem, deepcopy(resolved, PathItem.class)); @@ -605,7 +625,22 @@ public MediaType traverseMediaType(MediaType mediaType, ReferenceVisitor visitor resolved.setSchema(schema); } } + if (resolved.getItemSchema() != null) { + Schema itemSchema = traverseSchema(resolved.getItemSchema(), visitor, new ArrayList<>()); + if (itemSchema != null) { + resolved.setItemSchema(itemSchema); + } + } traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + if (resolved.getPrefixEncoding() != null) { + for (int i = 0; i < resolved.getPrefixEncoding().size(); i++) { + Encoding prefixEncoding = traverseEncoding(resolved.getPrefixEncoding().get(i), visitor); + if (prefixEncoding != null) { + resolved.getPrefixEncoding().set(i, prefixEncoding); + } + } + } + traverseEncoding(resolved.getItemEncoding(), visitor); traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(mediaType, resolved); visiting.remove(mediaType); @@ -630,6 +665,16 @@ public Encoding traverseEncoding(Encoding encoding, ReferenceVisitor visitor) { resolved = encoding; } traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); + traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + if (resolved.getPrefixEncoding() != null) { + for (int i = 0; i < resolved.getPrefixEncoding().size(); i++) { + Encoding prefixEncoding = traverseEncoding(resolved.getPrefixEncoding().get(i), visitor); + if (prefixEncoding != null) { + resolved.getPrefixEncoding().set(i, prefixEncoding); + } + } + } + traverseEncoding(resolved.getItemEncoding(), visitor); visitedMap.put(encoding, resolved); visiting.remove(encoding); return resolved; @@ -793,7 +838,8 @@ public Schema traverseSchema(Schema schema, ReferenceVisitor visitor, List T deepcopy(T entity, Class clazz) { try { - return (T)Json31.mapper().readValue(Json31.mapper().writeValueAsString(entity), clazz); + return (T)modelMapper().readValue(modelMapper().writeValueAsString(entity), clazz); } catch (JsonProcessingException e) { throw new RuntimeException(e); } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java index 94f97bef9d..9fc4b9c188 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPIDereferencer31.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.core.util.Json31; +import io.swagger.v3.core.util.Json32; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.parser.core.models.SwaggerParseResult; import org.apache.commons.lang3.StringUtils; @@ -50,7 +51,8 @@ public void dereference(DereferencerContext context, Iterator auths, PermittedUrlsChecker permittedUrlsChecker) throws Exception { if(context.getParseOptions().isSafelyResolveURL()){ diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java index 553b7d3a12..85fd39515b 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java @@ -46,6 +46,7 @@ import io.swagger.v3.oas.models.parameters.Parameter.StyleEnum; import io.swagger.v3.oas.models.parameters.PathParameter; import io.swagger.v3.oas.models.parameters.QueryParameter; +import io.swagger.v3.oas.models.parameters.QueryStringParameter; import io.swagger.v3.oas.models.parameters.RequestBody; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; @@ -194,6 +195,30 @@ public class OpenAPIDeserializer { protected static Set ENCODING_KEYS_31 = new LinkedHashSet<>(Arrays.asList("contentType", "headers", "style", "explode", "allowReserved")); + // OpenAPI 3.2 fixed-field additions over 3.1 + protected static Set ROOT_KEYS_32 = keys32(ROOT_KEYS_31, "$self"); + protected static Set TAG_KEYS_32 = keys32(TAG_KEYS_31, "summary", "parent", "kind"); + protected static Set RESPONSE_KEYS_32 = keys32(RESPONSE_KEYS_31, "summary"); + protected static Set SERVER_KEYS_32 = keys32(SERVER_KEYS_31, "name"); + protected static Set PATHITEM_KEYS_32 = keys32(PATHITEM_KEYS_31, "query", "additionalOperations"); + protected static Set COMPONENTS_KEYS_32 = keys32(COMPONENTS_KEYS_31, "mediaTypes"); + protected static Set MEDIATYPE_KEYS_32 = keys32(MEDIATYPE_KEYS_31, "$ref", "itemSchema", + "prefixEncoding", "itemEncoding"); + protected static Set EXAMPLE_KEYS_32 = keys32(EXAMPLE_KEYS_31, "dataValue", "serializedValue"); + protected static Set SECURITY_SCHEME_KEYS_32 = keys32(SECURITY_SCHEME_KEYS_31, "deprecated", + "oauth2MetadataUrl"); + protected static Set XML_KEYS_32 = keys32(XML_KEYS_31, "nodeType"); + protected static Set OAUTHFLOW_KEYS_32 = keys32(OAUTHFLOW_KEYS_31, "deviceAuthorizationUrl"); + protected static Set OAUTHFLOWS_KEYS_32 = keys32(OAUTHFLOWS_KEYS_31, "deviceAuthorization"); + protected static Set ENCODING_KEYS_32 = keys32(ENCODING_KEYS_31, "encoding", "prefixEncoding", + "itemEncoding"); + + private static Set keys32(Set keys31, String... additions) { + Set keys = new LinkedHashSet<>(keys31); + keys.addAll(Arrays.asList(additions)); + return keys; + } + protected static Map>> KEYS = new LinkedHashMap<>(); protected static Set validNodeTypes = new LinkedHashSet<>( @@ -255,10 +280,38 @@ public class OpenAPIDeserializer { KEYS.put("openapi30", keys30); KEYS.put("openapi31", keys31); + Map> keys32 = new LinkedHashMap<>(); + // unchanged categories are inherited from 3.1 + keys31.forEach(keys32::putIfAbsent); + keys32.put("ROOT_KEYS", ROOT_KEYS_32); + keys32.put("TAG_KEYS", TAG_KEYS_32); + keys32.put("RESPONSE_KEYS", RESPONSE_KEYS_32); + keys32.put("SERVER_KEYS", SERVER_KEYS_32); + keys32.put("PATHITEM_KEYS", PATHITEM_KEYS_32); + keys32.put("COMPONENTS_KEYS", COMPONENTS_KEYS_32); + keys32.put("MEDIATYPE_KEYS", MEDIATYPE_KEYS_32); + keys32.put("EXAMPLE_KEYS", EXAMPLE_KEYS_32); + keys32.put("SECURITY_SCHEME_KEYS", SECURITY_SCHEME_KEYS_32); + keys32.put("XML_KEYS", XML_KEYS_32); + keys32.put("OAUTHFLOW_KEYS", OAUTHFLOW_KEYS_32); + keys32.put("OAUTHFLOWS_KEYS", OAUTHFLOWS_KEYS_32); + keys32.put("ENCODING_KEYS", ENCODING_KEYS_32); + KEYS.put("openapi32", keys32); + + } + + private static Map> getSpecKeys(ParseResult result) { + if (result.isOpenapi32()) { + return KEYS.get("openapi32"); + } + return KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); } private static final String QUERY_PARAMETER = "query"; + private static final String QUERYSTRING_PARAMETER = "querystring"; private static final String COOKIE_PARAMETER = "cookie"; + private static final Set FIXED_PATH_METHODS = new LinkedHashSet<>(Arrays.asList( + "get", "put", "post", "delete", "head", "patch", "options", "trace", "query")); private static final String PATH_PARAMETER = "path"; private static final String HEADER_PARAMETER = "header"; private static final Pattern RFC3339_DATE_TIME_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):" + @@ -309,6 +362,7 @@ public SwaggerParseResult deserialize(JsonNode rootNode, String path, ParseOptio rootParse.setExplicitStyleAndExplode(options.isExplicitStyleAndExplode()); OpenAPI api = parseRoot(rootNode, rootParse, path); result.openapi31(rootParse.isOpenapi31()); + result.openapi32(rootParse.isOpenapi32()); result.setOpenAPI(api); result.setMessages(rootParse.getMessages()); } catch (Exception e) { @@ -335,13 +389,25 @@ public OpenAPI parseRoot(JsonNode node, ParseResult result, String path) { return null; } else if (value.startsWith("3.1") || value.startsWith("3.2")) { result.openapi31(true); - openAPI.setSpecVersion(SpecVersion.V31); + if (value.startsWith("3.2")) { + result.openapi32(true); + openAPI.setSpecVersion(SpecVersion.V32); + } else { + openAPI.setSpecVersion(SpecVersion.V31); + } } if (!value.startsWith("3.0.") && !value.startsWith("3.1.") && !value.startsWith("3.2.")){ result.warning(location, "The provided definition does not specify a valid version field"); } openAPI.setOpenapi(value); + if (result.isOpenapi32()) { + String self = getString("$self", rootNode, false, location, result); + if (StringUtils.isNotBlank(self)) { + openAPI.set$self(self); + } + } + ObjectNode obj = getObject("info", rootNode, true, location, result); if (obj != null) { @@ -442,7 +508,7 @@ public OpenAPI parseRoot(JsonNode node, ParseResult result, String path) { } Set keys = getKeys(rootNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("ROOT_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -586,6 +652,26 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res components.setCallbacks(getCallbacks(node, String.format("%s.%s", location, "callbacks"), result, true)); } + + if (result.isOpenapi32()) { + node = getObject("mediaTypes", obj, false, location, result); + if (node != null) { + Map mediaTypes = new LinkedHashMap<>(); + for (String name : getKeys(node)) { + JsonNode mediaTypeNode = node.get(name); + if (mediaTypeNode == null || !mediaTypeNode.isObject()) { + result.invalidType(location, "mediaTypes." + name, "object", mediaTypeNode); + continue; + } + MediaType mediaType = getMediaType((ObjectNode) mediaTypeNode, + String.format("%s.%s.%s", location, "mediaTypes", name), result); + if (mediaType != null) { + mediaTypes.put(name, mediaType); + } + } + components.setMediaTypes(mediaTypes); + } + } components.setExtensions(new LinkedHashMap<>()); Map extensions = getExtensions(obj); @@ -594,7 +680,7 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("COMPONENTS_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -645,6 +731,21 @@ public Tag getTag(ObjectNode obj, String location, ParseResult result) { tag.setDescription(value); } + if (result.isOpenapi32()) { + value = getString("summary", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + tag.setSummary(value); + } + value = getString("parent", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + tag.setParent(value); + } + value = getString("kind", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + tag.setKind(value); + } + } + ObjectNode docs = getObject("externalDocs", obj, false, location, result); ExternalDocumentation externalDocs = getExternalDocs(docs, String.format("%s.%s", location, "externalDocs"), result); @@ -658,7 +759,7 @@ public Tag getTag(ObjectNode obj, String location, ParseResult result) { } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("TAG_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -745,13 +846,20 @@ public Server getServer(ObjectNode obj, String location, ParseResult result, Str server.setDescription(value); } + if (result.isOpenapi32()) { + value = getString("name", obj, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + server.setName(value); + } + } + Map extensions = getExtensions(obj); if (extensions != null && extensions.size() > 0) { server.setExtensions(extensions); } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("SERVER_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -830,7 +938,7 @@ public ServerVariable getServerVariable(ObjectNode obj, String location, ParseRe } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("SERVER_VARIABLE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -1098,6 +1206,34 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) pathItem.setTrace(operation); } } + if (result.isOpenapi32()) { + node = getObject("query", obj, false, location, result); + if (node != null) { + Operation operation = getOperation(node, location + "(query)", result); + if (operation != null) { + pathItem.setQuery(operation); + } + } + node = getObject("additionalOperations", obj, false, location, result); + if (node != null) { + for (String method : getKeys(node)) { + if (FIXED_PATH_METHODS.contains(method.toLowerCase(Locale.ROOT))) { + result.warning(location, "additionalOperations key '" + method + + "' duplicates a fixed Path Item method name"); + } + JsonNode operationNode = node.get(method); + if (operationNode == null || !operationNode.isObject()) { + result.invalidType(location, "additionalOperations." + method, "object", operationNode); + continue; + } + Operation operation = getOperation((ObjectNode) operationNode, + location + "(additionalOperations." + method + ")", result); + if (operation != null) { + pathItem.addAdditionalOperation(method, operation); + } + } + } + } Map extensions = getExtensions(obj); if (extensions != null && extensions.size() > 0) { @@ -1105,7 +1241,7 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("PATHITEM_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -1113,6 +1249,37 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) validateReservedKeywords(specKeys, key, location, result); } + // OpenAPI 3.2: an in: querystring parameter must not coexist with in: query + // parameters (or a second querystring parameter) in the effective operation, + // which includes path-item level parameters + if (result.isOpenapi32() && pathItem.getParameters() != null) { + long pathQuerystring = pathItem.getParameters().stream() + .map(this::getParameterDefinition) + .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())).count(); + boolean pathQuery = pathItem.getParameters().stream() + .map(this::getParameterDefinition) + .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn())); + for (Operation op : pathItem.readOperations()) { + if (op == null || op.getParameters() == null) { + continue; + } + long opQuerystring = op.getParameters().stream() + .map(this::getParameterDefinition) + .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())).count(); + boolean opQuery = op.getParameters().stream() + .map(this::getParameterDefinition) + .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn())); + if (pathQuerystring > 0 && opQuerystring > 0) { + result.warning(location, + "There can be only one in: querystring parameter per operation"); + } + if ((pathQuerystring > 0 && opQuery) || (opQuerystring > 0 && pathQuery)) { + result.warning(location, + "in: querystring parameter cannot be combined with in: query parameters"); + } + } + } + return pathItem; } @@ -1139,7 +1306,7 @@ public ExternalDocumentation getExternalDocs(ObjectNode node, String location, P } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("EXTERNAL_DOCS_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1305,7 +1472,7 @@ public Info getInfo(ObjectNode node, String location, ParseResult result) { } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("INFO_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1359,7 +1526,7 @@ public License getLicense(ObjectNode node, String location, ParseResult result) } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("LICENSE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1402,7 +1569,7 @@ public Contact getContact(ObjectNode node, String location, ParseResult result) } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("CONTACT_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1437,6 +1604,24 @@ public MediaType getMediaType(ObjectNode contentNode, String location, ParseResu } MediaType mediaType = new MediaType(); + if (result.isOpenapi32()) { + JsonNode ref = contentNode.get("$ref"); + if (ref != null) { + if (ref.getNodeType().equals(JsonNodeType.STRING)) { + String mungedRef = mungedRef(ref.textValue()); + mediaType.set$ref(mungedRef != null ? mungedRef : ref.textValue()); + if (ref.textValue().startsWith("#/components") + && !ref.textValue().startsWith("#/components/mediaTypes")) { + result.warning(location, "$ref target " + ref.textValue() + + " is not of expected type MediaType"); + } + return mediaType; + } + result.invalidType(location, "$ref", "string", contentNode); + return null; + } + } + ObjectNode schemaObject = getObject("schema", contentNode, false, location, result); if (schemaObject != null) { mediaType.setSchema(getSchema(schemaObject, String.format("%s.%s", location, "schema"), result)); @@ -1484,9 +1669,38 @@ public MediaType getMediaType(ObjectNode contentNode, String location, ParseResu } } + if (result.isOpenapi32()) { + JsonNode itemSchemaNode = getObjectOrBoolean("itemSchema", contentNode, false, location, result); + if (itemSchemaNode != null) { + mediaType.setItemSchema(getSchema(itemSchemaNode, + String.format("%s.%s", location, "itemSchema"), result)); + } + ArrayNode prefixEncodingArray = getArray("prefixEncoding", contentNode, false, location, result); + if (prefixEncodingArray != null) { + List prefixEncoding = new ArrayList<>(); + for (JsonNode item : prefixEncodingArray) { + if (item.isObject()) { + Encoding encoding = getEncoding((ObjectNode) item, + String.format("%s.%s", location, "prefixEncoding"), result); + if (encoding != null) { + prefixEncoding.add(encoding); + } + } else { + result.invalidType(location, "prefixEncoding", "object", item); + } + } + mediaType.setPrefixEncoding(prefixEncoding); + } + ObjectNode itemEncodingObject = getObject("itemEncoding", contentNode, false, location, result); + if (itemEncodingObject != null) { + mediaType.setItemEncoding(getEncoding(itemEncodingObject, + String.format("%s.%s", location, "itemEncoding"), result)); + } + } + Set keys = getKeys(contentNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("MEDIATYPE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, contentNode.get(key)); @@ -1558,13 +1772,42 @@ public Encoding getEncoding(ObjectNode node, String location, ParseResult result encoding.setHeaders(getHeaders(headersObject, location, result, false)); } + if (result.isOpenapi32()) { + ObjectNode nestedEncoding = getObject("encoding", node, false, location, result); + if (nestedEncoding != null) { + encoding.setEncoding(getEncodingMap(nestedEncoding, + String.format("%s.%s", location, "encoding"), result)); + } + ArrayNode prefixEncodingArray = getArray("prefixEncoding", node, false, location, result); + if (prefixEncodingArray != null) { + List prefixEncoding = new ArrayList<>(); + for (JsonNode item : prefixEncodingArray) { + if (item.isObject()) { + Encoding nested = getEncoding((ObjectNode) item, + String.format("%s.%s", location, "prefixEncoding"), result); + if (nested != null) { + prefixEncoding.add(nested); + } + } else { + result.invalidType(location, "prefixEncoding", "object", item); + } + } + encoding.setPrefixEncoding(prefixEncoding); + } + ObjectNode itemEncodingObject = getObject("itemEncoding", node, false, location, result); + if (itemEncodingObject != null) { + encoding.setItemEncoding(getEncoding(itemEncodingObject, + String.format("%s.%s", location, "itemEncoding"), result)); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { encoding.setExtensions(extensions); } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("ENCODING_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1681,7 +1924,7 @@ public Link getLink(ObjectNode linkNode, String location, ParseResult result) { } Set keys = getKeys(linkNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("LINK_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, linkNode.get(key)); @@ -1807,6 +2050,17 @@ public XML getXml(ObjectNode node, String location, ParseResult result) { xml.setWrapped(wrapped); } + if (result.isOpenapi32()) { + value = getString("nodeType", node, false, String.format("%s.%s", location, "nodeType"), result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + xml.setNodeType(value); + } + if (value != null && (attribute != null || wrapped != null)) { + result.warning(location, + "nodeType cannot be used together with attribute/wrapped"); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { xml.setExtensions(extensions); @@ -1814,7 +2068,7 @@ public XML getXml(ObjectNode node, String location, ParseResult result) { Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("XML_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -1971,6 +2225,21 @@ public List getParameterList(ArrayNode obj, String location, ParseRes } } }); + if (result.isOpenapi32()) { + long queryStringParams = parameters.stream() + .map(this::getParameterDefinition) + .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())) + .count(); + if (queryStringParams > 1) { + result.warning(location, "There can be only one in: querystring parameter per location"); + } + if (queryStringParams > 0 && parameters.stream() + .map(this::getParameterDefinition) + .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn()))) { + result.warning(location, + "in: querystring parameter cannot be combined with in: query parameters"); + } + } return parameters; } @@ -2042,10 +2311,14 @@ public Parameter getParameter(ObjectNode obj, String location, ParseResult resul parameter = new PathParameter(); } else if (COOKIE_PARAMETER.equals(value)) { parameter = new CookieParameter(); + } else if (QUERYSTRING_PARAMETER.equals(value) && result.isOpenapi32()) { + parameter = new QueryStringParameter(); } if (parameter == null) { - result.invalidType(location, "in", "[query|header|path|cookie]", obj); + result.invalidType(location, "in", result.isOpenapi32() + ? "[query|header|path|cookie|querystring]" + : "[query|header|path|cookie]", obj); return null; } @@ -2132,6 +2405,27 @@ else if(parameter.getSchema() == null) { result.missing(location,"content"); } + if (QUERYSTRING_PARAMETER.equals(parameter.getIn())) { + // OpenAPI 3.2: a querystring parameter describes the whole query + // string through content; schema/style/explode/allowReserved/ + // allowEmptyValue/example(s) are not applicable + if (parameter.getSchema() != null) { + result.warning(location, "in: querystring parameter must not define schema"); + } + // when schema is absent the generic branch above already reports + // missing content; report it here only when it was suppressed + if (parameter.getContent() == null && parameter.getSchema() != null) { + result.missing(location, "content"); + } + for (String forbidden : Arrays.asList("style", "explode", "allowReserved", + "allowEmptyValue")) { + if (obj.get(forbidden) != null) { + result.warning(location, + "in: querystring parameter must not define " + forbidden); + } + } + } + value = getString("style", obj, false, location, result); if (parameter.getContent() == null) { setStyle(value, parameter, location, obj, result); @@ -2139,7 +2433,8 @@ else if(parameter.getSchema() == null) { Boolean explode = getBoolean("explode", obj, false, location, result); if (explode != null) { parameter.setExplode(explode); - } else if (StyleEnum.FORM.equals(parameter.getStyle()) && result.isExplicitStyleAndExplode()) { + } else if ((StyleEnum.FORM.equals(parameter.getStyle()) + || StyleEnum.COOKIE.equals(parameter.getStyle())) && result.isExplicitStyleAndExplode()) { parameter.setExplode(Boolean.TRUE); } else if (result.isExplicitStyleAndExplode()){ parameter.setExplode(Boolean.FALSE); @@ -2152,7 +2447,7 @@ else if(parameter.getSchema() == null) { } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("PARAMETER_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -2295,7 +2590,7 @@ public Header getHeader(ObjectNode headerNode, String location, ParseResult resu } Set oAuthFlowKeys = getKeys(headerNode); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : oAuthFlowKeys) { if (!specKeys.get("HEADER_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, headerNode.get(key)); @@ -2471,13 +2766,24 @@ public SecurityScheme getSecurityScheme(ObjectNode node, String location, ParseR securityScheme.setOpenIdConnectUrl(value); } + if (result.isOpenapi32()) { + Boolean deprecatedFlag = getBoolean("deprecated", node, false, location, result); + if (deprecatedFlag != null) { + securityScheme.setDeprecated(deprecatedFlag); + } + value = getString("oauth2MetadataUrl", node, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + securityScheme.setOauth2MetadataUrl(value); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { securityScheme.setExtensions(extensions); } Set securitySchemeKeys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : securitySchemeKeys) { if (!specKeys.get("SECURITY_SCHEME_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -2514,13 +2820,20 @@ public OAuthFlows getOAuthFlows(ObjectNode node, String location, ParseResult re oAuthFlows.setAuthorizationCode(getOAuthFlow("authorizationCode", objectNode, location, result)); } + if (result.isOpenapi32()) { + objectNode = getObject("deviceAuthorization", node, false, location, result); + if (objectNode != null) { + oAuthFlows.setDeviceAuthorization(getOAuthFlow("deviceAuthorization", objectNode, location, result)); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { oAuthFlows.setExtensions(extensions); } Set oAuthFlowKeys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : oAuthFlowKeys) { if (!specKeys.get("OAUTHFLOWS_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -2538,8 +2851,9 @@ public OAuthFlow getOAuthFlow(String oAuthFlowType, ObjectNode node, String loca OAuthFlow oAuthFlow = new OAuthFlow(); - boolean authorizationUrlRequired, tokenUrlRequired, refreshUrlRequired, scopesRequired; - authorizationUrlRequired = tokenUrlRequired = refreshUrlRequired = false; + boolean authorizationUrlRequired, tokenUrlRequired, refreshUrlRequired, scopesRequired, + deviceAuthorizationUrlRequired; + authorizationUrlRequired = tokenUrlRequired = refreshUrlRequired = deviceAuthorizationUrlRequired = false; scopesRequired = true; switch (oAuthFlowType) { case "implicit": @@ -2554,9 +2868,21 @@ public OAuthFlow getOAuthFlow(String oAuthFlowType, ObjectNode node, String loca case "authorizationCode": authorizationUrlRequired = tokenUrlRequired = true; break; + case "deviceAuthorization": + // OpenAPI 3.2 + deviceAuthorizationUrlRequired = tokenUrlRequired = true; + break; } - String value = getString("authorizationUrl", node, authorizationUrlRequired, location, result); + String value = null; + if (result.isOpenapi32()) { + value = getString("deviceAuthorizationUrl", node, deviceAuthorizationUrlRequired, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + oAuthFlow.setDeviceAuthorizationUrl(value); + } + } + + value = getString("authorizationUrl", node, authorizationUrlRequired, location, result); if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { oAuthFlow.setAuthorizationUrl(value); } @@ -2589,7 +2915,7 @@ public OAuthFlow getOAuthFlow(String oAuthFlowType, ObjectNode node, String loca } Set oAuthFlowKeys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : oAuthFlowKeys) { if (!specKeys.get("OAUTHFLOW_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -2648,6 +2974,13 @@ public Discriminator getDiscriminator(ObjectNode node, String location, ParseRes discriminator.setMapping(mapping); } + if (result.isOpenapi32()) { + String defaultMapping = getString("defaultMapping", node, false, location, result); + if (StringUtils.isNotBlank(defaultMapping)) { + discriminator.setDefaultMapping(defaultMapping); + } + } + if(result.isOpenapi31()) { Set keys = getKeys(node); for (String key : keys) { @@ -3375,13 +3708,27 @@ public Example getExample(ObjectNode node, String location, ParseResult result) result.warning(location, " value and externalValue are both present"); } + if (result.isOpenapi32()) { + Object dataValue = getAnyType("dataValue", node, location, result); + if (dataValue != null) { + example.setDataValue(dataValue instanceof NullNode ? null : dataValue); + example.setDataValueSetFlag(true); + } + + String serializedValue = getString("serializedValue", node, false, location, result); + if ((result.isAllowEmptyStrings() && serializedValue != null) + || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(serializedValue))) { + example.setSerializedValue(serializedValue); + } + } + Map extensions = getExtensions(node); if (extensions != null && extensions.size() > 0) { example.setExtensions(extensions); } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("EXAMPLE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -3417,6 +3764,13 @@ public void setStyle(String value, Parameter parameter, String location, ObjectN parameter.setStyle(StyleEnum.SIMPLE); } else if (value.equals(StyleEnum.SPACEDELIMITED.toString())) { parameter.setStyle(StyleEnum.SPACEDELIMITED); + } else if (value.equals(StyleEnum.COOKIE.toString()) && result.isOpenapi32()) { + // OpenAPI 3.2: 'cookie' style is defined for in: cookie parameters + parameter.setStyle(StyleEnum.COOKIE); + if (!COOKIE_PARAMETER.equals(parameter.getIn())) { + result.warning(location, + "style: cookie is only defined for in: cookie parameters"); + } } else { result.invalidType(location, "style", "StyleEnum", obj); } @@ -3496,6 +3850,13 @@ public ApiResponse getResponse(ObjectNode node, String location, ParseResult res apiResponse.description(value); } + if (result.isOpenapi32()) { + value = getString("summary", node, false, location, result); + if ((result.isAllowEmptyStrings() && value != null) || (!result.isAllowEmptyStrings() && !StringUtils.isBlank(value))) { + apiResponse.setSummary(value); + } + } + ObjectNode headerObject = getObject("headers", node, false, location, result); if (headerObject != null) { @@ -3524,7 +3885,7 @@ public ApiResponse getResponse(ObjectNode node, String location, ParseResult res } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("RESPONSE_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -3632,7 +3993,7 @@ public Operation getOperation(ObjectNode obj, String location, ParseResult resul } Set keys = getKeys(obj); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("OPERATION_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, obj.get(key)); @@ -3771,7 +4132,7 @@ public RequestBody getRequestBody(ObjectNode node, String location, ParseResult } Set keys = getKeys(node); - Map> specKeys = KEYS.get(result.isOpenapi31() ? "openapi31" : "openapi30"); + Map> specKeys = getSpecKeys(result); for (String key : keys) { if (!specKeys.get("REQUEST_BODY_KEYS").contains(key) && !key.startsWith("x-")) { result.extra(location, key, node.get(key)); @@ -4300,6 +4661,7 @@ public static class ParseResult { private boolean inferSchemaType = true; private boolean openapi31 = false; + private boolean openapi32 = false; private boolean oaiAuthor = false; private boolean explicitStyleAndExplode = true; @@ -4386,6 +4748,18 @@ public boolean isOpenapi31() { return this.openapi31; } + public void setOpenapi32(boolean openapi32) { + this.openapi32 = openapi32; + } + + public ParseResult openapi32(boolean openapi32) { + this.openapi32 = openapi32; + return this; + } + public boolean isOpenapi32() { + return this.openapi32; + } + public boolean isOaiAuthor() { return this.oaiAuthor; } diff --git a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java index b24b38c020..3d8089c233 100644 --- a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java +++ b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java @@ -47,7 +47,7 @@ public void testBasicOAS321() { assertTrue(result.isOpenapi31()); } - @Test(description = "3.2-specific fields do not crash parsing and are reported as unexpected attributes") + @Test(description = "3.2-specific fields are bound to the model") public void testOAS32SpecificFieldsRecorded() { String yaml = "openapi: 3.2.0\n" + "$self: https://example.com/api/openapi.yaml\n" + @@ -62,6 +62,7 @@ public void testOAS32SpecificFieldsRecorded() { " '200':\n" + " description: ok\n" + " query:\n" + + " operationId: queryPets\n" + " responses:\n" + " '200':\n" + " description: ok\n"; @@ -69,9 +70,12 @@ public void testOAS32SpecificFieldsRecorded() { OpenAPI openAPI = result.getOpenAPI(); assertNotNull(openAPI); assertNotNull(openAPI.getPaths().get("/pets").getGet()); - // 3.2-only members are not modeled yet: they surface as validation messages - assertTrue(result.getMessages().contains("attribute $self is unexpected")); - assertTrue(result.getMessages().contains("attribute paths.'/pets'.query is unexpected")); + // 3.2 members bind to the model now + assertEquals(openAPI.get$self(), "https://example.com/api/openapi.yaml"); + assertNotNull(openAPI.getPaths().get("/pets").getQuery()); + assertEquals(openAPI.getPaths().get("/pets").getQuery().getOperationId(), "queryPets"); + assertTrue(result.isOpenapi32()); + assertEquals(openAPI.getSpecVersion(), io.swagger.v3.oas.models.SpecVersion.V32); // 3.1-family fields continue to work assertEquals(openAPI.getJsonSchemaDialect(), "https://json-schema.org/draft/2020-12/schema"); } @@ -141,7 +145,7 @@ public void testOAS32ResolveRoutesTo31Dereferencer() { assertEquals(openAPI.getPaths().get("/pets").getGet().getOperationId(), "listPets"); } - @Test(description = "3.2 'in: querystring' parameter is dropped with a validation message (current best-effort)") + @Test(description = "3.2 'in: querystring' parameter binds as QueryStringParameter; missing content is reported") public void testOAS32QuerystringParamReported() { String yaml = "openapi: 3.2.0\n" + "info:\n" + @@ -151,19 +155,37 @@ public void testOAS32QuerystringParamReported() { " /pets:\n" + " get:\n" + " parameters:\n" + - " - name: q\n" + - " in: querystring\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + " responses:\n" + " '200':\n" + " description: ok\n"; SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); OpenAPI openAPI = result.getOpenAPI(); assertNotNull(openAPI); - // the parameter is not representable yet: it is dropped and reported - assertTrue(openAPI.getPaths().get("/pets").getGet().getParameters() == null - || openAPI.getPaths().get("/pets").getGet().getParameters().isEmpty()); - assertTrue(result.getMessages().stream() - .anyMatch(m -> m.contains("in is not of type `[query|header|path|cookie]`"))); + assertEquals(openAPI.getPaths().get("/pets").getGet().getParameters().size(), 1); + assertTrue(openAPI.getPaths().get("/pets").getGet().getParameters().get(0) + instanceof io.swagger.v3.oas.models.parameters.QueryStringParameter); + assertNotNull(openAPI.getPaths().get("/pets").getGet().getParameters().get(0).getContent()); + + // querystring without content is invalid per spec + String noContent = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: 1.0.0\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult bad = new OpenAPIV3Parser().readContents(noContent, null, null); + assertTrue(bad.getMessages().stream().anyMatch(m -> m.contains("content"))); } @Test(description = "bare 3.2 (no patch version) is accepted per upstream's loose convention, with a warning") @@ -224,4 +246,694 @@ public void testUnrecognizedMinorVersionRejected() { SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); assertNull(result.getOpenAPI()); } + + @Test(description = "additionalOperations bind to the PathItem model; keys keep their case") + public void testOAS32AdditionalOperations() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " operationId: getPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " operationId: purgePets\n" + + " responses:\n" + + " '204':\n" + + " description: purged\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNotNull(openAPI.getPaths().get("/pets").getAdditionalOperations()); + assertEquals(openAPI.getPaths().get("/pets").getAdditionalOperations() + .get("PURGE").getOperationId(), "purgePets"); + } + + @Test(description = "additionalOperations key duplicating a fixed method is detected") + public void testOAS32AdditionalOperationsDuplicateDetected() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " additionalOperations:\n" + + " get:\n" + + " operationId: dupGet\n" + + " responses:\n" + + " '200':\n" + + " description: dup\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("duplicates a fixed Path Item method name"))); + // lenient: the operation is still bound + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getAdditionalOperations().get("get")); + } + + @Test(description = "additionalOperations work inside webhooks and components.pathItems") + public void testOAS32AdditionalOperationsNested() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "webhooks:\n" + + " hook:\n" + + " additionalOperations:\n" + + " NOTIFY:\n" + + " operationId: notify\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " pathItems:\n" + + " Shared:\n" + + " additionalOperations:\n" + + " SEARCH:\n" + + " operationId: search\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getWebhooks().get("hook").getAdditionalOperations() + .get("NOTIFY").getOperationId(), "notify"); + assertEquals(openAPI.getComponents().getPathItems().get("Shared") + .getAdditionalOperations().get("SEARCH").getOperationId(), "search"); + } + + @Test(description = "in: querystring is rejected for 3.1 documents") + public void testQuerystringRejectedOn31() { + String yaml = "openapi: 3.1.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertFalse(result.isOpenapi32()); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("in is not of type"))); + } + + @Test(description = "duplicate querystring parameters and querystring+query coexistence are detected") + public void testQuerystringConstraints() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " - name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("only one in: querystring"))); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "style: cookie binds for in: cookie in 3.2 (explode defaults true), warns on other locations, rejected for 3.1") + public void testCookieStyle() { + String responses = " responses:\n" + + " '200':\n" + + " description: ok\n"; + // valid: in: cookie + style: cookie (explode defaults to true per spec) + String validParams = " parameters:\n" + + " - name: session\n" + + " in: cookie\n" + + " style: cookie\n" + + " schema:\n" + + " type: string\n"; + SwaggerParseResult v32 = new OpenAPIV3Parser().readContents( + "openapi: 3.2.0\ninfo:\n title: t\n version: '1'\npaths:\n /p:\n get:\n" + + validParams + responses, null, null); + io.swagger.v3.oas.models.parameters.Parameter cookieParam = + v32.getOpenAPI().getPaths().get("/p").getGet().getParameters().get(0); + assertEquals(cookieParam.getStyle(), + io.swagger.v3.oas.models.parameters.Parameter.StyleEnum.COOKIE); + assertEquals(cookieParam.getExplode(), Boolean.TRUE); + assertFalse(v32.getMessages().stream() + .anyMatch(m -> m.contains("only defined for in: cookie"))); + + // invalid: style: cookie with in: query + String invalidParams = " parameters:\n" + + " - name: session\n" + + " in: query\n" + + " style: cookie\n" + + " schema:\n" + + " type: string\n"; + SwaggerParseResult v32bad = new OpenAPIV3Parser().readContents( + "openapi: 3.2.0\ninfo:\n title: t\n version: '1'\npaths:\n /p:\n get:\n" + + invalidParams + responses, null, null); + assertTrue(v32bad.getMessages().stream() + .anyMatch(m -> m.contains("only defined for in: cookie"))); + + SwaggerParseResult v31 = new OpenAPIV3Parser().readContents( + "openapi: 3.1.0\ninfo:\n title: t\n version: '1'\npaths:\n /p:\n get:\n" + + validParams + responses, null, null); + assertTrue(v31.getMessages().stream() + .anyMatch(m -> m.contains("style is not of type"))); + } + + @Test(description = "components.mediaTypes and content map $ref values bind under 3.2") + public void testOAS32MediaTypesAndContentRef() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/Pet'\n" + + "components:\n" + + " mediaTypes:\n" + + " Pet:\n" + + " schema:\n" + + " type: object\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").get$ref(), + "#/components/mediaTypes/Pet"); + assertNotNull(openAPI.getComponents().getMediaTypes()); + assertNotNull(openAPI.getComponents().getMediaTypes().get("Pet").getSchema()); + } + + @Test(description = "content map $ref is not bound under 3.1 (MediaType has no $ref there)") + public void testContentRefNotBound31() { + String yaml = "openapi: 3.1.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/Pet'\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertNull(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").get$ref()); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("$ref") && m.contains("unexpected"))); + } + + @Test(description = "3.2 scalar fields bind: server.name, tag.summary/parent/kind, response.summary, example.dataValue/serializedValue, securityScheme.deprecated/oauth2MetadataUrl, deviceAuthorization flow, xml.nodeType, discriminator.defaultMapping, mediaType.itemSchema/encoding") + public void testOAS32NewScalarFields() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "servers:\n" + + " - url: https://api.example.com\n" + + " name: production\n" + + "tags:\n" + + " - name: pet\n" + + " summary: Pet operations\n" + + " parent: root\n" + + " kind: nav\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " summary: pet list\n" + + " content:\n" + + " application/json:\n" + + " itemSchema:\n" + + " type: string\n" + + "components:\n" + + " securitySchemes:\n" + + " oauth:\n" + + " type: oauth2\n" + + " deprecated: true\n" + + " oauth2MetadataUrl: https://example.com/.well-known/oauth-authorization-server\n" + + " flows:\n" + + " deviceAuthorization:\n" + + " deviceAuthorizationUrl: https://example.com/device\n" + + " tokenUrl: https://example.com/token\n" + + " scopes: {}\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertEquals(openAPI.getServers().get(0).getName(), "production"); + assertEquals(openAPI.getTags().get(0).getSummary(), "Pet operations"); + assertEquals(openAPI.getTags().get(0).getParent(), "root"); + assertEquals(openAPI.getTags().get(0).getKind(), "nav"); + assertEquals(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getSummary(), "pet list"); + assertNotNull(openAPI.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").getItemSchema()); + io.swagger.v3.oas.models.security.SecurityScheme scheme = + openAPI.getComponents().getSecuritySchemes().get("oauth"); + assertEquals(scheme.getDeprecated(), Boolean.TRUE); + assertEquals(scheme.getOauth2MetadataUrl(), + "https://example.com/.well-known/oauth-authorization-server"); + assertEquals(scheme.getFlows().getDeviceAuthorization().getDeviceAuthorizationUrl(), + "https://example.com/device"); + assertEquals(scheme.getFlows().getDeviceAuthorization().getTokenUrl(), + "https://example.com/token"); + } + + @Test(description = "3.2 example dataValue/serializedValue bind; value+dataValue coexistence warns") + public void testOAS32ExampleFields() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths: {}\n" + + "components:\n" + + " examples:\n" + + " ex:\n" + + " dataValue:\n" + + " name: fido\n" + + " serializedValue: '{\"name\":\"fido\"}'\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + io.swagger.v3.oas.models.examples.Example ex = + result.getOpenAPI().getComponents().getExamples().get("ex"); + assertNotNull(ex.getDataValue()); + assertEquals(ex.getSerializedValue(), "{\"name\":\"fido\"}"); + } + + @Test(description = "3.2 xml.nodeType and discriminator.defaultMapping bind") + public void testOAS32XmlAndDiscriminator() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths: {}\n" + + "components:\n" + + " schemas:\n" + + " Pet:\n" + + " type: object\n" + + " xml:\n" + + " nodeType: element\n" + + " discriminator:\n" + + " propertyName: kind\n" + + " defaultMapping: '#/components/schemas/Dog'\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + io.swagger.v3.oas.models.media.Schema pet = + result.getOpenAPI().getComponents().getSchemas().get("Pet"); + assertEquals(pet.getXml().getNodeType(), "element"); + assertEquals(pet.getDiscriminator().getDefaultMapping(), "#/components/schemas/Dog"); + } + + @Test(description = "3.2 nested encodings (encoding/prefixEncoding/itemEncoding) bind") + public void testOAS32NestedEncodings() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /upload:\n" + + " post:\n" + + " requestBody:\n" + + " content:\n" + + " multipart/mixed:\n" + + " schema:\n" + + " type: object\n" + + " prefixEncoding:\n" + + " - contentType: application/json\n" + + " itemEncoding:\n" + + " contentType: text/plain\n" + + " encoding:\n" + + " part:\n" + + " contentType: application/octet-stream\n" + + " itemEncoding:\n" + + " contentType: application/json\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + io.swagger.v3.oas.models.media.MediaType mt = result.getOpenAPI().getPaths() + .get("/upload").getPost().getRequestBody().getContent().get("multipart/mixed"); + assertEquals(mt.getPrefixEncoding().size(), 1); + assertEquals(mt.getPrefixEncoding().get(0).getContentType(), "application/json"); + assertEquals(mt.getItemEncoding().getContentType(), "text/plain"); + assertEquals(mt.getEncoding().get("part").getItemEncoding().getContentType(), + "application/json"); + } + + @Test(description = "3.2 fields are reported unexpected and not bound under 3.1") + public void testOAS32FieldsNotBound31() { + String yaml = "openapi: 3.1.0\n" + + "$self: https://example.com/openapi.yaml\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "servers:\n" + + " - url: https://api.example.com\n" + + " name: production\n" + + "paths:\n" + + " /pets:\n" + + " query:\n" + + " operationId: q\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " mediaTypes:\n" + + " Pet:\n" + + " schema:\n" + + " type: object\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + assertFalse(result.isOpenapi32()); + assertNull(openAPI.get$self()); + assertNull(openAPI.getServers().get(0).getName()); + assertNull(openAPI.getPaths().get("/pets").getQuery()); + assertNull(openAPI.getComponents().getMediaTypes()); + assertTrue(result.getMessages().contains("attribute $self is unexpected")); + assertTrue(result.getMessages().contains("attribute .servers.name is unexpected")); + assertTrue(result.getMessages().contains("attribute paths.'/pets'.query is unexpected")); + assertTrue(result.getMessages().contains("attribute components.mediaTypes is unexpected")); + } + + @Test(description = "JSON round-trip: parse -> serialize via Json32 -> re-parse preserves 3.2 fields") + public void testOAS32JsonRoundTrip() throws Exception { + String json = "{\n" + + " \"openapi\": \"3.2.0\",\n" + + " \"$self\": \"https://example.com/openapi.json\",\n" + + " \"info\": {\"title\": \"t\", \"version\": \"1\"},\n" + + " \"paths\": {\n" + + " \"/pets\": {\n" + + " \"get\": {\n" + + " \"operationId\": \"getPets\",\n" + + " \"responses\": {\"200\": {\"description\": \"ok\",\n" + + " \"content\": {\"application/json\": {\"$ref\": \"#/components/mediaTypes/Pet\"}}}},\n" + + " \"parameters\": [{\"in\": \"querystring\",\n" + + " \"content\": {\"application/json\": {\"schema\": {\"type\": \"object\"}}}}]\n" + + " },\n" + + " \"additionalOperations\": {\"PURGE\": {\"operationId\": \"purge\",\n" + + " \"responses\": {\"204\": {\"description\": \"done\"}}}}\n" + + " }\n" + + " },\n" + + " \"components\": {\"mediaTypes\": {\"Pet\": {\"schema\": {\"type\": \"object\"}}}}\n" + + "}"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(json, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + + String serialized = io.swagger.v3.core.util.Json32.pretty(openAPI); + SwaggerParseResult reparsed = new OpenAPIV3Parser().readContents(serialized, null, null); + OpenAPI again = reparsed.getOpenAPI(); + assertNotNull(again); + assertEquals(again.get$self(), "https://example.com/openapi.json"); + assertEquals(again.getPaths().get("/pets").getAdditionalOperations() + .get("PURGE").getOperationId(), "purge"); + assertEquals(again.getPaths().get("/pets").getGet().getResponses().get("200") + .getContent().get("application/json").get$ref(), "#/components/mediaTypes/Pet"); + assertTrue(again.getPaths().get("/pets").getGet().getParameters().get(0) + instanceof io.swagger.v3.oas.models.parameters.QueryStringParameter); + assertNotNull(again.getComponents().getMediaTypes().get("Pet")); + } + + @Test(description = "YAML round-trip: parse -> serialize via Yaml32 -> re-parse preserves 3.2 fields") + public void testOAS32YamlRoundTrip() throws Exception { + String yaml = "openapi: 3.2.0\n" + + "$self: https://example.com/openapi.yaml\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " query:\n" + + " operationId: queryPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " operationId: purgePets\n" + + " responses:\n" + + " '204':\n" + + " description: done\n" + + "components:\n" + + " mediaTypes:\n" + + " Pet:\n" + + " schema:\n" + + " type: object\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + + String serialized = io.swagger.v3.core.util.Yaml32.pretty(openAPI); + SwaggerParseResult reparsed = new OpenAPIV3Parser().readContents(serialized, null, null); + OpenAPI again = reparsed.getOpenAPI(); + assertNotNull(again); + assertEquals(again.get$self(), "https://example.com/openapi.yaml"); + assertEquals(again.getPaths().get("/pets").getQuery().getOperationId(), "queryPets"); + assertEquals(again.getPaths().get("/pets").getAdditionalOperations() + .get("PURGE").getOperationId(), "purgePets"); + assertNotNull(again.getComponents().getMediaTypes().get("Pet")); + } + + @Test(description = "name remains REQUIRED for in: querystring parameters") + public void testQuerystringNameRequired() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("name"))); + } + + @Test(description = "example/examples are permitted common fields on in: querystring parameters") + public void testQuerystringAllowsExamples() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - name: query\n" + + " in: querystring\n" + + " example: a=1&b=2\n" + + " content:\n" + + " application/x-www-form-urlencoded:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertFalse(result.getMessages().stream() + .anyMatch(m -> m.contains("must not define example"))); + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getGet() + .getParameters().get(0).getExample()); + } + + @Test(description = "querystring/query conflict is detected across path-item and operation parameters") + public void testQuerystringCrossLevelConflict() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " parameters:\n" + + " - name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n" + + " get:\n" + + " parameters:\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + + // conflict via a local components.parameters $ref is also detected + String refYaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - $ref: '#/components/parameters/q'\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " parameters:\n" + + " q:\n" + + " name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n"; + SwaggerParseResult refResult = new OpenAPIV3Parser().readContents(refYaml, null, null); + assertTrue(refResult.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "deviceAuthorizationUrl is not bound for 3.0/3.1 documents") + public void testDeviceAuthorizationUrlVersionGated() { + String tail = "components:\n" + + " securitySchemes:\n" + + " oauth:\n" + + " type: oauth2\n" + + " flows:\n" + + " implicit:\n" + + " authorizationUrl: https://example.com/auth\n" + + " deviceAuthorizationUrl: https://example.com/device\n"; + SwaggerParseResult v31 = new OpenAPIV3Parser().readContents( + "openapi: 3.1.0\ninfo:\n title: t\n version: '1'\npaths: {}\n" + tail, null, null); + assertNull(v31.getOpenAPI().getComponents().getSecuritySchemes().get("oauth") + .getFlows().getImplicit().getDeviceAuthorizationUrl()); + + SwaggerParseResult v32 = new OpenAPIV3Parser().readContents( + "openapi: 3.2.0\ninfo:\n title: t\n version: '1'\npaths: {}\n" + tail, null, null); + assertEquals(v32.getOpenAPI().getComponents().getSecuritySchemes().get("oauth") + .getFlows().getImplicit().getDeviceAuthorizationUrl(), "https://example.com/device"); + } + + @Test(description = "boolean itemSchema is accepted") + public void testItemSchemaBoolean() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json-seq:\n" + + " itemSchema: false\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertNotNull(result.getOpenAPI().getPaths().get("/pets").getGet() + .getResponses().get("200").getContent().get("application/json-seq").getItemSchema()); + assertFalse(result.getMessages().stream() + .anyMatch(m -> m.contains("itemSchema"))); + } + + @Test(description = "resolve=true preserves 3.2 fields in $ref'd PathItems and resolves MediaType references") + public void testResolvePreserves32Fields() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " $ref: '#/components/pathItems/petsPath'\n" + + "components:\n" + + " pathItems:\n" + + " petsPath:\n" + + " query:\n" + + " operationId: queryPets\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/PetMedia'\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " operationId: purgePets\n" + + " responses:\n" + + " '204':\n" + + " description: done\n" + + " mediaTypes:\n" + + " PetMedia:\n" + + " schema:\n" + + " type: object\n"; + ParseOptions options = new ParseOptions(); + options.setResolve(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + OpenAPI openAPI = result.getOpenAPI(); + assertNotNull(openAPI); + // local pathItem refs resolve into components.pathItems (upstream behavior) + io.swagger.v3.oas.models.PathItem pets = + openAPI.getComponents().getPathItems().get("petsPath"); + assertNotNull(pets); + assertNotNull(pets.getQuery()); + assertEquals(pets.getQuery().getOperationId(), "queryPets"); + assertNotNull(pets.getAdditionalOperations()); + assertEquals(pets.getAdditionalOperations().get("PURGE").getOperationId(), "purgePets"); + // media type reference resolved + io.swagger.v3.oas.models.media.MediaType mt = pets.getQuery().getResponses().get("200") + .getContent().get("application/json"); + assertNotNull(mt); + assertNull(mt.get$ref()); + assertNotNull(mt.getSchema()); + } } diff --git a/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java b/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java index f8b3f3f480..192c70baa3 100644 --- a/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java +++ b/modules/swagger-parser/src/test/java/io/swagger/parser/OpenAPIParserTest.java @@ -795,7 +795,8 @@ public void testIssue1552AdditionalProps() throws Exception { " type: string\n" + " additionalProperties: false\n" + " x-original-swagger-version: \"2.0\"\n" + - "openapi31: false\n"); + "openapi31: false\n" + + "openapi32: false\n"); } } diff --git a/pom.xml b/pom.xml index f21c8096eb..246ded81f5 100644 --- a/pom.xml +++ b/pom.xml @@ -362,7 +362,7 @@ 1.0.76 2.22.0 2.0.18 - 2.2.53 + 2.2.56-SNAPSHOT 1.6.16 4.13.2 7.12.0 From 5bccd14c35656db7a6b4f78304b31a4035184e16 Mon Sep 17 00:00:00 2001 From: Kuniyuki Hayashi Date: Tue, 22 Sep 2026 09:26:26 +0900 Subject: [PATCH 3/3] Fix OpenAPI 3.2 reference resolution and validation issues - IdsTraverser: traverse mediaTypes, query, additionalOperations, itemSchema, and nested encodings so forward $id targets registered only in those 3.2 containers resolve correctly - getComponents: assign this.components early and parse pathItems last so components.pathItems operations can resolve local parameter refs - getAllOperationsInAPath: use PathItem.readOperations() so path template parameter validation covers query and additionalOperations - getParameterDefinition: only resolve local #/ refs; external refs must not be compared against unrelated local parameters - querystring conflict check: evaluate the effective parameter set (name+in dedup) so legitimate operation-level overrides do not warn - OpenAPI31Traverser.traverseMediaType: keep root-local refs via shouldHandleRootLocalRefs/handleRootLocalRefs like other types, storing resolved values under components.mediaTypes --- .../v3/parser/reference/IdsTraverser.java | 33 +++ .../parser/reference/OpenAPI31Traverser.java | 15 +- .../v3/parser/util/OpenAPIDeserializer.java | 72 +++--- .../parser/test/OAI32DeserializationTest.java | 228 +++++++++++++++++- 4 files changed, 308 insertions(+), 40 deletions(-) diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java index 16fcf1533a..82d899dd87 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/IdsTraverser.java @@ -111,6 +111,7 @@ public Components traverseComponents(Components components, Visitor visitor) { traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); traverseMap(resolved.getLinks(), visitor, this::traverseLink); traverseMap(resolved.getResponses(), visitor, this::traverseResponse); + traverseMap(resolved.getMediaTypes(), visitor, this::traverseMediaType); traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(components, resolved); @@ -309,6 +310,12 @@ public PathItem traversePathItem(PathItem pathItem, Visitor visitor) { if (resolvedOperation != null) { resolved.setTrace(resolvedOperation); } + Operation queryOp = resolved.getQuery(); + resolvedOperation = traverseOperation(queryOp, visitor); + if (resolvedOperation != null) { + resolved.setQuery(resolvedOperation); + } + traverseMap(resolved.getAdditionalOperations(), visitor, this::traverseOperation); if (resolved.getParameters() != null) { for (int i = 0; i < resolved.getParameters().size(); i++) { @@ -410,7 +417,15 @@ public MediaType traverseMediaType(MediaType mediaType, Visitor visitor) { if (resolved.getSchema() != null) { traverseSchema(resolved.getSchema(), visitor, new ArrayList<>()); } + if (resolved.getItemSchema() != null) { + traverseSchema(resolved.getItemSchema(), visitor, new ArrayList<>()); + } traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + traverseEncodingList(resolved.getPrefixEncoding(), visitor); + Encoding itemEncoding = traverseEncoding(resolved.getItemEncoding(), visitor); + if (itemEncoding != null) { + resolved.setItemEncoding(itemEncoding); + } traverseMap(resolved.getExamples(), visitor, this::traverseExample); visitedMap.put(mediaType, resolved); visiting.remove(mediaType); @@ -432,12 +447,30 @@ public Encoding traverseEncoding(Encoding encoding, Visitor visitor) { Encoding resolved = encoding; traverseMap(resolved.getHeaders(), visitor, this::traverseHeader); + traverseMap(resolved.getEncoding(), visitor, this::traverseEncoding); + traverseEncodingList(resolved.getPrefixEncoding(), visitor); + Encoding itemEncoding = traverseEncoding(resolved.getItemEncoding(), visitor); + if (itemEncoding != null) { + resolved.setItemEncoding(itemEncoding); + } visitedMap.put(encoding, resolved); visiting.remove(encoding); return resolved; } + private void traverseEncodingList(List encodings, Visitor visitor) { + if (encodings == null) { + return; + } + for (int i = 0; i < encodings.size(); i++) { + Encoding resolved = traverseEncoding(encodings.get(i), visitor); + if (resolved != null) { + encodings.set(i, resolved); + } + } + } + public Header traverseHeader(Header header, Visitor visitor) { if (header == null) { return null; diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java index 5cbe30c865..f84aa1adb6 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/OpenAPI31Traverser.java @@ -615,8 +615,12 @@ public MediaType traverseMediaType(MediaType mediaType, ReferenceVisitor visitor visiting.add(mediaType); MediaType resolved = visitor.visitMediaType(mediaType); + boolean resolvedNotNull = false; + if (resolved == null) { resolved = mediaType; + } else { + resolvedNotNull = true; } if (resolved.getSchema() != null) { @@ -642,7 +646,16 @@ public MediaType traverseMediaType(MediaType mediaType, ReferenceVisitor visitor } traverseEncoding(resolved.getItemEncoding(), visitor); traverseMap(resolved.getExamples(), visitor, this::traverseExample); - visitedMap.put(mediaType, resolved); + + // only if this is root and local ref + if (shouldHandleRootLocalRefs(resolvedNotNull, mediaType.get$ref(), visitor)) { + ensureComponents(context.getOpenApi()); + if (context.getOpenApi().getComponents().getMediaTypes() == null) context.getOpenApi().getComponents().mediaTypes(new LinkedHashMap<>()); + visitedMap.put(mediaType, deepcopy(mediaType, MediaType.class)); + visiting.remove(mediaType); + return handleRootLocalRefs(mediaType.get$ref(), resolved, context.getOpenApi().getComponents().getMediaTypes()); + } + visitedMap.put(mediaType, deepcopy(resolved, MediaType.class)); visiting.remove(mediaType); return resolved; } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java index 85fd39515b..86f9876801 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java @@ -597,6 +597,10 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res return null; } Components components = new Components(); + // expose the in-progress components so that nested parsing (e.g. + // components.pathItems operations referencing components.parameters) + // can resolve local refs while this object is still being built + this.components = components; ObjectNode node = getObject("schemas", obj, false, location, result); if (node != null) { @@ -608,13 +612,6 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res components.setResponses(getResponses(node, String.format("%s.%s", location, "responses"), result, true)); } - if(result.isOpenapi31()){ - node = getObject("pathItems", obj, false, location, result); - if (node != null) { - components.setPathItems(getPathItems(node, String.format("%s.%s", location, "pathItems"), result, true)); - } - } - node = getObject("parameters", obj, false, location, result); if (node != null) { components.setParameters(getParameters(node, String.format("%s.%s", location, "parameters"), result, @@ -672,6 +669,14 @@ public Components getComponents(ObjectNode obj, String location, ParseResult res components.setMediaTypes(mediaTypes); } } + // pathItems are parsed last so that operations inside components.pathItems + // can resolve refs to parameters/schemas/etc defined in this components + if(result.isOpenapi31()){ + node = getObject("pathItems", obj, false, location, result); + if (node != null) { + components.setPathItems(getPathItems(node, String.format("%s.%s", location, "pathItems"), result, true)); + } + } components.setExtensions(new LinkedHashMap<>()); Map extensions = getExtensions(obj); @@ -1069,24 +1074,10 @@ private boolean isPathParamDefined(String pathParam, List parameters) return true; } - private void addToOperationsList(List operationsList, Operation operation) { - if (operation == null) { - return; - } - operationsList.add(operation); - } - public List getAllOperationsInAPath(PathItem pathObj) { - List operations = new ArrayList<>(); - addToOperationsList(operations, pathObj.getGet()); - addToOperationsList(operations, pathObj.getPut()); - addToOperationsList(operations, pathObj.getPost()); - addToOperationsList(operations, pathObj.getPatch()); - addToOperationsList(operations, pathObj.getDelete()); - addToOperationsList(operations, pathObj.getTrace()); - addToOperationsList(operations, pathObj.getOptions()); - addToOperationsList(operations, pathObj.getHead()); - return operations; + // readOperations() covers the fixed methods plus query and + // additionalOperations entries + return pathObj.readOperations(); } public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) { @@ -1251,29 +1242,31 @@ public PathItem getPathItem(ObjectNode obj, String location, ParseResult result) // OpenAPI 3.2: an in: querystring parameter must not coexist with in: query // parameters (or a second querystring parameter) in the effective operation, - // which includes path-item level parameters + // which includes path-item level parameters. Operation parameters override + // path-item parameters sharing the same name+in, so the check runs on the + // effective parameter set rather than the raw union. if (result.isOpenapi32() && pathItem.getParameters() != null) { - long pathQuerystring = pathItem.getParameters().stream() - .map(this::getParameterDefinition) - .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())).count(); - boolean pathQuery = pathItem.getParameters().stream() - .map(this::getParameterDefinition) - .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn())); for (Operation op : pathItem.readOperations()) { if (op == null || op.getParameters() == null) { continue; } - long opQuerystring = op.getParameters().stream() + Map effective = new LinkedHashMap<>(); + pathItem.getParameters().stream() .map(this::getParameterDefinition) - .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())).count(); - boolean opQuery = op.getParameters().stream() + .filter(p -> p.getIn() != null) + .forEach(p -> effective.put(p.getName() + "#" + p.getIn(), p)); + op.getParameters().stream() .map(this::getParameterDefinition) - .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn())); - if (pathQuerystring > 0 && opQuerystring > 0) { + .filter(p -> p.getIn() != null) + .forEach(p -> effective.put(p.getName() + "#" + p.getIn(), p)); + long querystringCount = effective.values().stream() + .filter(p -> QUERYSTRING_PARAMETER.equals(p.getIn())).count(); + if (querystringCount > 1) { result.warning(location, "There can be only one in: querystring parameter per operation"); } - if ((pathQuerystring > 0 && opQuery) || (opQuerystring > 0 && pathQuery)) { + if (querystringCount > 0 && effective.values().stream() + .anyMatch(p -> QUERY_PARAMETER.equals(p.getIn()))) { result.warning(location, "in: querystring parameter cannot be combined with in: query parameters"); } @@ -2244,7 +2237,10 @@ public List getParameterList(ArrayNode obj, String location, ParseRes } private Parameter getParameterDefinition(Parameter parameter) { - if (parameter.get$ref() == null) { + if (parameter.get$ref() == null || !parameter.get$ref().startsWith("#/")) { + // non-local refs are not resolvable here; an external ref like + // "external.yaml#/components/parameters/q" must not be compared + // against an unrelated local parameter that happens to share the name return parameter; } Object parameterSchemaName = extractSimpleName(parameter.get$ref()).getLeft(); diff --git a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java index 3d8089c233..b3d72af08e 100644 --- a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java +++ b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OAI32DeserializationTest.java @@ -929,11 +929,237 @@ public void testResolvePreserves32Fields() { assertEquals(pets.getQuery().getOperationId(), "queryPets"); assertNotNull(pets.getAdditionalOperations()); assertEquals(pets.getAdditionalOperations().get("PURGE").getOperationId(), "purgePets"); - // media type reference resolved + // media type root-local ref is preserved like every other component type + // (resolved value is stored under components.mediaTypes) io.swagger.v3.oas.models.media.MediaType mt = pets.getQuery().getResponses().get("200") .getContent().get("application/json"); assertNotNull(mt); + assertEquals(mt.get$ref(), "#/components/mediaTypes/PetMedia"); + assertNotNull(openAPI.getComponents().getMediaTypes()); + assertNotNull(openAPI.getComponents().getMediaTypes().get("PetMedia").getSchema()); + } + + @Test(description = "resolveFully inlines mediaType refs") + public void testResolveFullyInlinesMediaTypeRef() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " $ref: '#/components/mediaTypes/PetMedia'\n" + + "components:\n" + + " mediaTypes:\n" + + " PetMedia:\n" + + " schema:\n" + + " type: object\n"; + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + io.swagger.v3.oas.models.media.MediaType mt = result.getOpenAPI().getPaths().get("/pets") + .getGet().getResponses().get("200").getContent().get("application/json"); + assertNotNull(mt); assertNull(mt.get$ref()); assertNotNull(mt.getSchema()); } + + @Test(description = "forward $id refs resolve when the $id target lives in 3.2 containers") + public void testForwardIdRefsIn32Containers() { + // $id target nested inside components.mediaTypes schema + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: 'https://example.com/schemas/pet'\n" + + "components:\n" + + " mediaTypes:\n" + + " PetMedia:\n" + + " schema:\n" + + " $id: https://example.com/schemas/pet\n" + + " type: object\n" + + " properties:\n" + + " name:\n" + + " type: string\n"; + ParseOptions options = new ParseOptions(); + options.setResolve(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, options); + Schema schema = result.getOpenAPI().getPaths().get("/pets").getGet() + .getResponses().get("200").getContent().get("application/json").getSchema(); + assertNotNull(schema); + assertNull(schema.get$ref()); + assertTrue(schema.getTypes().contains("object")); + + // $id target inside a query operation of components.pathItems + String yaml2 = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $ref: 'https://example.com/schemas/pet2'\n" + + "components:\n" + + " pathItems:\n" + + " petPath:\n" + + " query:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " $id: https://example.com/schemas/pet2\n" + + " type: object\n"; + SwaggerParseResult result2 = new OpenAPIV3Parser().readContents(yaml2, null, options); + Schema schema2 = result2.getOpenAPI().getPaths().get("/pets").getGet() + .getResponses().get("200").getContent().get("application/json").getSchema(); + assertNotNull(schema2); + assertNull(schema2.get$ref()); + assertTrue(schema2.getTypes().contains("object")); + } + + @Test(description = "querystring conflict detection works inside components.pathItems") + public void testQuerystringConflictInComponentsPathItems() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "components:\n" + + " parameters:\n" + + " q:\n" + + " name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n" + + " pathItems:\n" + + " petsPath:\n" + + " get:\n" + + " parameters:\n" + + " - $ref: '#/components/parameters/q'\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertTrue(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "a same-name querystring parameter on the operation is a legal override") + public void testQuerystringOverrideNotDuplicate() { + String base = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " parameters:\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " get:\n" + + " parameters:\n" + + " - name: %s\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n"; + // same name: operation-level param overrides the path-level one + SwaggerParseResult ok = new OpenAPIV3Parser().readContents(String.format(base, "qs"), null, null); + assertFalse(ok.getMessages().stream() + .anyMatch(m -> m.contains("querystring"))); + // different name: two distinct querystring params in the effective set + SwaggerParseResult dup = new OpenAPIV3Parser().readContents(String.format(base, "other"), null, null); + assertTrue(dup.getMessages().stream() + .anyMatch(m -> m.contains("only one in: querystring parameter per operation"))); + } + + @Test(description = "external parameter refs are not compared against unrelated local parameters") + public void testExternalRefNotMiscompared() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets:\n" + + " get:\n" + + " parameters:\n" + + " - $ref: 'external.yaml#/components/parameters/q'\n" + + " - name: qs\n" + + " in: querystring\n" + + " content:\n" + + " application/json:\n" + + " schema:\n" + + " type: object\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + "components:\n" + + " parameters:\n" + + " q:\n" + + " name: q\n" + + " in: query\n" + + " schema:\n" + + " type: string\n"; + // the local in:query parameter "q" must not be confused with the + // unresolved external ref that happens to end with the same name + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertFalse(result.getMessages().stream() + .anyMatch(m -> m.contains("cannot be combined with in: query"))); + } + + @Test(description = "path template params are validated inside query and additionalOperations") + public void testPathTemplateValidationCoversQueryAndAdditional() { + String yaml = "openapi: 3.2.0\n" + + "info:\n" + + " title: t\n" + + " version: '1'\n" + + "paths:\n" + + " /pets/{petId}:\n" + + " query:\n" + + " responses:\n" + + " '200':\n" + + " description: ok\n" + + " additionalOperations:\n" + + " PURGE:\n" + + " responses:\n" + + " '204':\n" + + " description: done\n"; + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yaml, null, null); + assertEquals(result.getMessages().stream() + .filter(m -> m.contains("needs to be defined")).count(), 2); + } }