From 93f4a1a86c142dc53b568ad63f22fbc9c3465c3b Mon Sep 17 00:00:00 2001 From: Kuniyuki Hayashi Date: Mon, 21 Sep 2026 03:34:55 +0900 Subject: [PATCH] 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()); + } +}