From 84c1d5c1d1d6aab2b1fc998ec63e81f3c1320507 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Tue, 12 May 2026 12:53:30 +0300 Subject: [PATCH] fix(parser-v3): preserve $dynamicRef/$dynamicAnchor and fix $id scope leak in OAS 3.1 dereferencer Four changes to the OAS 3.1 dereferencer to handle specs using $dynamicRef, $dynamicAnchor, $defs, and $id without crashing or losing data: 1. resolveSchemaRef(): bypass inheritedIds for #/components/ and #/definitions/ refs so a schema's $id cannot hijack document-level resolution. Also clear inheritedIds during traversal of resolved components to prevent $id scope from leaking into nested refs. 2. findAnchor(): match $dynamicAnchor in addition to $anchor so anchor-style fragments resolve correctly. 3. traverseSchema(): traverse $defs sub-schemas from extensions so nested refs and anchors inside $defs are available to consumers. 4. mergeSchemas(): copy $dynamicRef and $dynamicAnchor from source to target so these fields survive the merge cycle. Closes #2331 --- .../parser/reference/OpenAPI31Traverser.java | 26 ++++ .../v3/parser/reference/ReferenceVisitor.java | 24 +++- .../test/OpenAPIV3ParserDynamicRefTest.java | 126 +++++++++++++++++- .../dynamicRef/dynamic-anchor-resolution.yaml | 20 +++ .../dynamicRef/dynamicref-recursive.yaml | 28 ++++ .../dynamicRef/id-with-ref-nested-doc.yaml | 15 +++ .../dynamicRef/id-with-ref-nested-file.yaml | 12 ++ .../dynamicRef/id-with-ref-nested-target.yaml | 4 + .../resources/dynamicRef/id-with-ref.yaml | 15 +++ 9 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamic-anchor-resolution.yaml create mode 100644 modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamicref-recursive.yaml create mode 100644 modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-doc.yaml create mode 100644 modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-file.yaml create mode 100644 modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-target.yaml create mode 100644 modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref.yaml 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 ca61dac25b..3689d02253 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 @@ -911,6 +911,26 @@ public Schema traverseSchema(Schema schema, ReferenceVisitor visitor, List defsMap = (Map) defsRaw; + Map resolvedDefs = new LinkedHashMap<>(); + for (Map.Entry entry : defsMap.entrySet()) { + if (entry.getValue() instanceof Map) { + @SuppressWarnings("unchecked") + Map defSchemaRaw = (Map) entry.getValue(); + Schema defSchema = Json31.mapper().convertValue(defSchemaRaw, Schema.class); + Schema traversedDef = traverseSchema(defSchema, visitor, inheritedIds); + Schema effectiveDef = traversedDef != null ? traversedDef : defSchema; + resolvedDefs.put(entry.getKey(), Json31.mapper().convertValue(effectiveDef, Map.class)); + } else { + resolvedDefs.put(entry.getKey(), entry.getValue()); + } + } + resolved.addExtension("$defs", resolvedDefs); + } + // only if this is root and local ref if (shouldHandleRootLocalRefs(resolvedNotNull, schema.get$ref(), visitor)) { @@ -1073,6 +1093,12 @@ public void mergeSchemas(Schema source, Schema target) { if (source.get$anchor() != null){ target.set$anchor(source.get$anchor()); } + if (source.get$dynamicAnchor() != null){ + target.set$dynamicAnchor(source.get$dynamicAnchor()); + } + if (source.get$dynamicRef() != null){ + target.set$dynamicRef(source.get$dynamicRef()); + } if (source.get$comment() != null){ target.set$comment(source.get$comment()); } diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/ReferenceVisitor.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/ReferenceVisitor.java index d2d70b809e..792e3cbee0 100644 --- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/ReferenceVisitor.java +++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/reference/ReferenceVisitor.java @@ -26,6 +26,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.ArrayList; import java.util.Map; import java.util.function.BiFunction; @@ -229,11 +230,16 @@ public T resolveRef(T visiting, String ref, Class clazz, BiFunction inheritedIds){ try { - String baseURI = this.reference.getUri(); - for (String id: inheritedIds) { - String urlWithoutHash = ReferenceUtils.toBaseURI(id); - baseURI = ReferenceUtils.resolve(urlWithoutHash, baseURI); - baseURI = ReferenceUtils.toBaseURI(baseURI); + String baseURI; + if (ref.startsWith("#/components/") || ref.startsWith("#/definitions/")) { + baseURI = this.reference.getUri(); + } else { + baseURI = this.reference.getUri(); + for (String id : inheritedIds) { + String urlWithoutHash = ReferenceUtils.toBaseURI(id); + baseURI = ReferenceUtils.resolve(urlWithoutHash, baseURI); + baseURI = ReferenceUtils.toBaseURI(baseURI); + } } baseURI = ReferenceUtils.resolve(ref, baseURI); baseURI = ReferenceUtils.toBaseURI(baseURI); @@ -269,8 +275,10 @@ public Schema resolveSchemaRef(Schema visiting, String ref, List inherit if (isAnchor) { resolved.$anchor(null); } + boolean isOpenApiDocumentRef = ref.startsWith("#/components/") || ref.startsWith("#/definitions/"); + List traversalIds = isOpenApiDocumentRef ? new ArrayList<>() : inheritedIds; ReferenceVisitor visitor = new ReferenceVisitor(referenceObject, openAPITraverser, this.visited, this.visitedMap, context); - return openAPITraverser.traverseSchema(resolved, visitor, inheritedIds); + return openAPITraverser.traverseSchema(resolved, visitor, traversalIds); } catch (Exception e) { LOGGER.error("Error resolving schema " + ref, e); this.reference.getMessages().add(e.getMessage()); @@ -284,6 +292,10 @@ public JsonNode findAnchor(JsonNode root, String anchor) { if (anchorNode != null && anchorNode.isValueNode() && anchor.equals(anchorNode.asText())) { return root; } + JsonNode dynamicAnchorNode = root.get("$dynamicAnchor"); + if (dynamicAnchorNode != null && dynamicAnchorNode.isValueNode() && anchor.equals(dynamicAnchorNode.asText())) { + return root; + } Iterator fieldNames = root.fieldNames(); while(fieldNames.hasNext()) { String fieldName = fieldNames.next(); diff --git a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OpenAPIV3ParserDynamicRefTest.java b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OpenAPIV3ParserDynamicRefTest.java index d62da22aa5..f85c19a124 100644 --- a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OpenAPIV3ParserDynamicRefTest.java +++ b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/test/OpenAPIV3ParserDynamicRefTest.java @@ -2,13 +2,25 @@ 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 java.util.List; +import java.util.Map; + import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; public class OpenAPIV3ParserDynamicRefTest { + + private ParseOptions resolveOptions() { + ParseOptions options = new ParseOptions(); + options.setResolve(true); + return options; + } + @Test public void testDynamicRefParsing() { SwaggerParseResult result = new OpenAPIV3Parser() @@ -32,7 +44,119 @@ public void testDynamicRefParsing() { Schema childrenSchema = (Schema) nodeSchema.getProperties().get("children"); Schema itemsSchema = childrenSchema.getItems(); - // THIS is the actual test: you should have a get$dynamicRef() field assertEquals("#node", itemsSchema.get$dynamicRef(), "Expected $dynamicRef to be preserved"); } + + @Test + public void testIdWithComponentRef() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/id-with-ref.yaml", null, resolveOptions()); + + assertTrue(result.getMessages() == null || result.getMessages().isEmpty(), + "Expected no parse errors, got: " + result.getMessages()); + + Schema concrete = result.getOpenAPI().getComponents().getSchemas().get("Concrete"); + assertNotNull(concrete, "Concrete schema should exist"); + assertNotNull(concrete.get$ref(), "Concrete should still have $ref"); + } + + @Test + public void testDynamicAnchorPreservedAfterResolution() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/dynamic-anchor-resolution.yaml", null, resolveOptions()); + + assertTrue(result.getMessages() == null || result.getMessages().isEmpty(), + "Expected no parse errors, got: " + result.getMessages()); + + Schema node = result.getOpenAPI().getComponents().getSchemas().get("Node"); + assertNotNull(node, "Node schema should exist"); + assertEquals(node.get$id(), "https://example.com/schemas/Node"); + + Schema childrenSchema = (Schema) node.getProperties().get("children"); + Schema itemsSchema = childrenSchema.getItems(); + assertEquals(itemsSchema.get$dynamicRef(), "#node", + "$dynamicRef should be preserved after resolution"); + } + + @Test + public void testDefsTraversed() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/dynamic-anchor-resolution.yaml", null, resolveOptions()); + + Schema node = result.getOpenAPI().getComponents().getSchemas().get("Node"); + assertNotNull(node.getExtensions(), "Extensions should not be null"); + assertNotNull(node.getExtensions().get("$defs"), "$defs should be preserved in extensions"); + + @SuppressWarnings("unchecked") + Map defs = (Map) node.getExtensions().get("$defs"); + assertTrue(defs.containsKey("node"), "$defs should contain 'node' key"); + } + + @Test + public void testDynamicRefRecursiveOverride() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/dynamicref-recursive.yaml", null, resolveOptions()); + + assertTrue(result.getMessages() == null || result.getMessages().isEmpty(), + "Expected no parse errors, got: " + result.getMessages()); + + Schema base = result.getOpenAPI().getComponents().getSchemas().get("BaseCategory"); + assertNotNull(base, "BaseCategory schema should exist"); + assertEquals(base.get$dynamicAnchor(), "category", + "$dynamicAnchor should be preserved on BaseCategory"); + + Schema childrenSchema = (Schema) base.getProperties().get("children"); + Schema itemsSchema = childrenSchema.getItems(); + assertEquals(itemsSchema.get$dynamicRef(), "#category", + "$dynamicRef should be preserved on BaseCategory children items"); + + Schema localized = result.getOpenAPI().getComponents().getSchemas().get("LocalizedCategory"); + assertNotNull(localized, "LocalizedCategory schema should exist"); + assertEquals(localized.get$dynamicAnchor(), "category", + "$dynamicAnchor should be preserved on LocalizedCategory"); + } + + @Test + public void testMergeSchemasPreservesDynamicFields() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/dynamicref-recursive.yaml", null, resolveOptions()); + + Schema localized = result.getOpenAPI().getComponents().getSchemas().get("LocalizedCategory"); + assertNotNull(localized, "LocalizedCategory schema should exist"); + + assertEquals(localized.get$dynamicAnchor(), "category", + "mergeSchemas should preserve $dynamicAnchor from source"); + } + + @Test + public void testIdWithComponentRefNestedDocRef() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/id-with-ref-nested-doc.yaml", null, resolveOptions()); + + assertTrue(result.getMessages() == null || result.getMessages().isEmpty(), + "Expected no parse errors, got: " + result.getMessages()); + + Schema concrete = result.getOpenAPI().getComponents().getSchemas().get("Concrete"); + assertNotNull(concrete, "Concrete schema should exist"); + + Schema other = result.getOpenAPI().getComponents().getSchemas().get("Other"); + assertNotNull(other, "Other schema should exist"); + } + + @Test + public void testIdWithComponentRefNestedFileRef() { + SwaggerParseResult result = new OpenAPIV3Parser() + .readLocation("dynamicRef/id-with-ref-nested-file.yaml", null, resolveOptions()); + + assertTrue(result.getMessages() == null || result.getMessages().isEmpty(), + "Expected no parse errors, got: " + result.getMessages()); + + Schema concrete = result.getOpenAPI().getComponents().getSchemas().get("Concrete"); + assertNotNull(concrete, "Concrete schema should exist"); + + Schema template = result.getOpenAPI().getComponents().getSchemas().get("Template"); + assertNotNull(template, "Template schema should exist"); + assertNotNull(template.getProperties(), "Template should have resolved properties from external file"); + assertNotNull(template.getProperties().get("message"), "Template should have 'message' property"); + } } diff --git a/modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamic-anchor-resolution.yaml b/modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamic-anchor-resolution.yaml new file mode 100644 index 0000000000..7a28767852 --- /dev/null +++ b/modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamic-anchor-resolution.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: $dynamicAnchor preservation + version: 1.0.0 +paths: {} +components: + schemas: + Node: + $id: "https://example.com/schemas/Node" + type: object + properties: + name: + type: string + children: + type: array + items: + $dynamicRef: "#node" + $defs: + node: + $dynamicAnchor: node diff --git a/modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamicref-recursive.yaml b/modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamicref-recursive.yaml new file mode 100644 index 0000000000..e1ed103263 --- /dev/null +++ b/modules/swagger-parser-v3/src/test/resources/dynamicRef/dynamicref-recursive.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Recursive dynamicRef + version: 1.0.0 +paths: {} +components: + schemas: + BaseCategory: + $dynamicAnchor: category + type: object + properties: + id: + type: string + children: + type: array + items: + $dynamicRef: "#category" + LocalizedCategory: + $dynamicAnchor: category + allOf: + - $ref: "#/components/schemas/BaseCategory" + - type: object + required: [displayName, locale] + properties: + displayName: + type: string + locale: + type: string diff --git a/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-doc.yaml b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-doc.yaml new file mode 100644 index 0000000000..53a1efce6b --- /dev/null +++ b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-doc.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: $id with $ref and nested document ref + version: 1.0.0 +paths: {} +components: + schemas: + Other: + type: string + format: date + Template: + $ref: "#/components/schemas/Other" + Concrete: + $id: "https://example.com/schemas/Concrete" + $ref: "#/components/schemas/Template" diff --git a/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-file.yaml b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-file.yaml new file mode 100644 index 0000000000..9749a880e7 --- /dev/null +++ b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-file.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: $id with $ref and nested file ref + version: 1.0.0 +paths: {} +components: + schemas: + Template: + $ref: "id-with-ref-nested-target.yaml" + Concrete: + $id: "https://example.com/schemas/Concrete" + $ref: "#/components/schemas/Template" diff --git a/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-target.yaml b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-target.yaml new file mode 100644 index 0000000000..6acdcbdd6d --- /dev/null +++ b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref-nested-target.yaml @@ -0,0 +1,4 @@ +type: object +properties: + message: + type: string diff --git a/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref.yaml b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref.yaml new file mode 100644 index 0000000000..b630ad47af --- /dev/null +++ b/modules/swagger-parser-v3/src/test/resources/dynamicRef/id-with-ref.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: $id with $ref + version: 1.0.0 +paths: {} +components: + schemas: + Template: + type: object + properties: + value: + type: string + Concrete: + $id: "https://example.com/schemas/Concrete" + $ref: "#/components/schemas/Template"