From 97b2b45d56a4bbf80b9e0da7ed3908c7343cfba5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 16 Jun 2025 15:28:57 -0400 Subject: [PATCH 1/5] chore: adds additional multiple levels of references test cases --- .../V31Tests/RelativeReferenceTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index 70b0c96a9..09774b6af 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -434,6 +434,39 @@ public async Task ShouldResolveReferencesInSchemasFromSystemTextJson() var updatedTagsProperty = Assert.IsType(schema.Properties["tags"]); Assert.Equal(absoluteReferenceId, updatedTagsProperty.Reference.ReferenceV3); Assert.Equal(JsonSchemaType.Array | JsonSchemaType.Null, updatedTagsProperty.Type); + Assert.Equal(JsonSchemaType.Object, updatedTagsProperty.Items.Type); + + var pathItem = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new OpenApiOperation + { + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + } + }, + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("#/components/schemas/Foo", document) + } + } + } + } + } + }; + document.Paths.Add("/", pathItem); + + var requestBodySchema = pathItem.Operations[HttpMethod.Post].RequestBody.Content["application/json"].Schema; + Assert.NotNull(requestBodySchema); + var requestBodyTagsProperty = Assert.IsType(requestBodySchema.Properties["tags"]); + Assert.Equal(JsonSchemaType.Object, requestBodyTagsProperty.Items.Type); } } } From c515a76d40df62af6f6bfc3d8c8c4f31f103d511 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 16 Jun 2025 15:34:44 -0400 Subject: [PATCH 2/5] chore: adds update to parent property Signed-off-by: Vincent Biret --- .../V31Tests/RelativeReferenceTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index 09774b6af..ee64beadf 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -436,6 +436,16 @@ public async Task ShouldResolveReferencesInSchemasFromSystemTextJson() Assert.Equal(JsonSchemaType.Array | JsonSchemaType.Null, updatedTagsProperty.Type); Assert.Equal(JsonSchemaType.Object, updatedTagsProperty.Items.Type); + + // doing the same for the parent property + + var parentProperty = Assert.IsType(schema.Properties["parent"]); + var parentSubProperty = Assert.IsType(parentProperty.Properties["parent"]); + Assert.Equal("#/properties/parent", parentSubProperty.Reference.ReferenceV3); + parentProperty.Properties["parent"] = new OpenApiSchemaReference($"#/components/schemas/Foo{parentSubProperty.Reference.ReferenceV3.Replace("#", string.Empty)}", document); + var updatedParentSubProperty = Assert.IsType(parentProperty.Properties["parent"]); + Assert.Equal(JsonSchemaType.Object | JsonSchemaType.Null, updatedParentSubProperty.Type); + var pathItem = new OpenApiPathItem { Operations = new Dictionary From 06cc025dca43d24955bcd205facefa4347d3f0c7 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 18 Jun 2025 14:27:19 -0400 Subject: [PATCH 3/5] fix: avoid stack overflow on cyclical references Signed-off-by: Vincent Biret --- .../Models/OpenApiDocument.cs | 10 +++--- .../References/BaseOpenApiReferenceHolder.cs | 2 +- .../Services/OpenApiWorkspace.cs | 23 +++++++++----- .../V2Tests/OpenApiDocumentTests.cs | 2 +- .../V31Tests/RelativeReferenceTests.cs | 31 +++++++++++++++++-- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index abf6a0748..031eadb5d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -498,10 +498,10 @@ public void SetReferenceHostDocument() /// /// Load the referenced object from a object /// - internal T? ResolveReferenceTo(BaseOpenApiReference reference) where T : IOpenApiReferenceable + internal T? ResolveReferenceTo(BaseOpenApiReference reference, IOpenApiSchema? parentSchema) where T : IOpenApiReferenceable { - if (ResolveReference(reference, reference.IsExternal) is T result) + if (ResolveReference(reference, reference.IsExternal, parentSchema) is T result) { return result; } @@ -566,7 +566,7 @@ private static string ConvertByteArrayToString(byte[] hash) /// /// Load the referenced object from a object /// - internal IOpenApiReferenceable? ResolveReference(BaseOpenApiReference? reference, bool useExternal) + internal IOpenApiReferenceable? ResolveReference(BaseOpenApiReference? reference, bool useExternal, IOpenApiSchema? parentSchema) { if (reference == null) { @@ -621,9 +621,9 @@ private static string ConvertByteArrayToString(byte[] hash) false => new Uri(uriLocation).AbsoluteUri }; - if (reference.Type is ReferenceType.Schema && absoluteUri.Contains('#')) + if (reference.Type is ReferenceType.Schema && absoluteUri.Contains('#') && parentSchema is not null) { - return Workspace?.ResolveJsonSchemaReference(absoluteUri); + return Workspace?.ResolveJsonSchemaReference(absoluteUri, parentSchema); } return Workspace?.ResolveReference(absoluteUri); diff --git a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs index 3f90276ff..634c5a723 100644 --- a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs +++ b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs @@ -15,7 +15,7 @@ public virtual U? Target get { if (Reference.HostDocument is null) return default; - return Reference.HostDocument.ResolveReferenceTo(Reference); + return Reference.HostDocument.ResolveReferenceTo(Reference, this as IOpenApiSchema); } } /// diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index 65fccb15a..ad1ea8aa2 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -331,8 +331,9 @@ public bool Contains(string location) /// Recursively resolves a schema from a URI fragment. /// /// + /// The parent schema to resolve against. /// - internal IOpenApiSchema? ResolveJsonSchemaReference(string location) + internal IOpenApiSchema? ResolveJsonSchemaReference(string location, IOpenApiSchema parentSchema) { /* Enables resolving references for nested subschemas * Examples: @@ -362,14 +363,22 @@ public bool Contains(string location) { // traverse remaining segments after fetching the base schema var remainingSegments = pathSegments.Skip(4).ToArray(); - return ResolveSubSchema(targetSchema, remainingSegments); + var stack = new Stack(); + stack.Push(parentSchema); + return ResolveSubSchema(targetSchema, remainingSegments, stack); } return default; } - internal static IOpenApiSchema? ResolveSubSchema(IOpenApiSchema schema, string[] pathSegments) + internal static IOpenApiSchema? ResolveSubSchema(IOpenApiSchema schema, string[] pathSegments, Stack visitedSchemas) { + // Prevent infinite recursion in case of circular references + if (visitedSchemas.Contains(schema)) + { + return null; + } + visitedSchemas.Push(schema); // Traverse schema object to resolve subschemas if (pathSegments.Length == 0) { @@ -383,13 +392,13 @@ public bool Contains(string location) case OpenApiConstants.Properties: var propName = pathSegments[0]; if (schema.Properties != null && schema.Properties.TryGetValue(propName, out var propSchema)) - return ResolveSubSchema(propSchema, [.. pathSegments.Skip(1)]); + return ResolveSubSchema(propSchema, [.. pathSegments.Skip(1)], visitedSchemas); break; case OpenApiConstants.Items: - return schema.Items is OpenApiSchema itemsSchema ? ResolveSubSchema(itemsSchema, pathSegments) : null; + return schema.Items is OpenApiSchema itemsSchema ? ResolveSubSchema(itemsSchema, pathSegments, visitedSchemas) : null; case OpenApiConstants.AdditionalProperties: - return schema.AdditionalProperties is OpenApiSchema additionalSchema ? ResolveSubSchema(additionalSchema, pathSegments) : null; + return schema.AdditionalProperties is OpenApiSchema additionalSchema ? ResolveSubSchema(additionalSchema, pathSegments, visitedSchemas) : null; case OpenApiConstants.AllOf: case OpenApiConstants.AnyOf: case OpenApiConstants.OneOf: @@ -405,7 +414,7 @@ public bool Contains(string location) // recurse into the indexed subschema if valid if (list != null && index < list.Count) - return ResolveSubSchema(list[index], [.. pathSegments.Skip(1)]); + return ResolveSubSchema(list[index], [.. pathSegments.Skip(1)], visitedSchemas); break; } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs index 5845e4bbf..36ecdc849 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentTests.cs @@ -261,7 +261,7 @@ public async Task ShouldAllowComponentsThatJustContainAReference() var schema1 = actual.Components.Schemas["AllPets"]; var schema1Reference = Assert.IsType(schema1); Assert.False(schema1Reference.UnresolvedReference); - var schema2 = actual.ResolveReferenceTo(schema1Reference.Reference); + var schema2 = actual.ResolveReferenceTo(schema1Reference.Reference, schema1Reference); Assert.IsType(schema2); if (string.IsNullOrEmpty(schema1Reference.Reference.Id) || schema1Reference.UnresolvedReference) { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index ee64beadf..cbb9eea6f 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -223,7 +223,7 @@ public void ResolveSubSchema_ShouldTraverseKnownKeywords() var path = new[] { "properties", "a", "properties", "b" }; - var result = OpenApiWorkspace.ResolveSubSchema(schema, path); + var result = OpenApiWorkspace.ResolveSubSchema(schema, path, []); Assert.NotNull(result); Assert.Equal(JsonSchemaType.String, result!.Type); @@ -250,7 +250,7 @@ public void ResolveSubSchema_ShouldHandleUserDefinedKeywordNamedProperty(string[ } }; - var result = OpenApiWorkspace.ResolveSubSchema(schema, pathSegments); + var result = OpenApiWorkspace.ResolveSubSchema(schema, pathSegments, []); Assert.NotNull(result); Assert.Equal(JsonSchemaType.String, result!.Type); @@ -275,7 +275,7 @@ public void ResolveSubSchema_ShouldRecurseIntoAllOfComposition() var path = new[] { "allOf", "0", "properties", "x" }; - var result = OpenApiWorkspace.ResolveSubSchema(schema, path); + var result = OpenApiWorkspace.ResolveSubSchema(schema, path, []); Assert.NotNull(result); Assert.Equal(JsonSchemaType.Integer, result!.Type); @@ -478,5 +478,30 @@ public async Task ShouldResolveReferencesInSchemasFromSystemTextJson() var requestBodyTagsProperty = Assert.IsType(requestBodySchema.Properties["tags"]); Assert.Equal(JsonSchemaType.Object, requestBodyTagsProperty.Items.Type); } + + [Fact] + public void ExitsEarlyOnCyclicalReferences() + { + var document = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + }; + var categorySchema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["parent"] = new OpenApiSchemaReference("#/components/schemas/Category", document), + // this is intentionally wrong and cyclical reference + // it tests whether we're going in an infinite resolution loop + ["tags"] = new OpenApiSchemaReference("#/components/schemas/Category/properties/parent/properties/tags", document) + } + }; + document.AddComponent("Category", categorySchema); + document.RegisterComponents(); + + Assert.Null(categorySchema.Properties["tags"].Items); + } } } From 7855b97af7765e21659735dfd6cb53903ae91d8b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 18 Jun 2025 14:29:02 -0400 Subject: [PATCH 4/5] chore: adds additional validation to test Signed-off-by: Vincent Biret --- .../V31Tests/RelativeReferenceTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index cbb9eea6f..ff13a7a11 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -501,7 +501,10 @@ public void ExitsEarlyOnCyclicalReferences() document.AddComponent("Category", categorySchema); document.RegisterComponents(); - Assert.Null(categorySchema.Properties["tags"].Items); + var tagsSchemaRef = Assert.IsType(categorySchema.Properties["tags"]); + Assert.Null(tagsSchemaRef.Items); + Assert.Equal("#/components/schemas/Category/properties/parent/properties/tags", tagsSchemaRef.Reference.ReferenceV3); + Assert.Null(tagsSchemaRef.Target); } } } From b7a070b2b915c97bf12cef6157454c742d9717cd Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 18 Jun 2025 14:36:08 -0400 Subject: [PATCH 5/5] chore: adds additional validation to the unit test Signed-off-by: Vincent Biret --- .../V31Tests/RelativeReferenceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index ff13a7a11..c32f63664 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -505,6 +505,10 @@ public void ExitsEarlyOnCyclicalReferences() Assert.Null(tagsSchemaRef.Items); Assert.Equal("#/components/schemas/Category/properties/parent/properties/tags", tagsSchemaRef.Reference.ReferenceV3); Assert.Null(tagsSchemaRef.Target); + + var parentSchemaRef = Assert.IsType(categorySchema.Properties["parent"]); + Assert.Equal("#/components/schemas/Category", parentSchemaRef.Reference.ReferenceV3); + Assert.NotNull(parentSchemaRef.Target); } } }