Skip to content

fix: a bug where relative relative and sub-component JSON references would not resolve properly #2389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
/// <summary>
/// The OpenApiDocument that is hosting the OpenApiReference instance. This is used to enable dereferencing the reference.
/// </summary>
public OpenApiDocument? HostDocument { get => hostDocument; init => hostDocument = value; }

Check warning on line 58 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Make this an auto-implemented property and remove its backing field. (https://rules.sonarsource.com/csharp/RSPEC-2292)

Check warning on line 58 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Make this an auto-implemented property and remove its backing field. (https://rules.sonarsource.com/csharp/RSPEC-2292)

Check warning on line 58 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Make this an auto-implemented property and remove its backing field. (https://rules.sonarsource.com/csharp/RSPEC-2292)

private string? _referenceV3;
/// <summary>
Expand Down Expand Up @@ -84,8 +84,8 @@
{
return Id;
}
if (!string.IsNullOrEmpty(Id) && Id is not null && Id.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||

Check warning on line 87 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 87 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)
!string.IsNullOrEmpty(Id) && Id is not null && Id.StartsWith("https://", StringComparison.OrdinalIgnoreCase))

Check warning on line 88 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)

Check warning on line 88 in src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs

View workflow job for this annotation

GitHub Actions / Build

Change this condition so that it does not always evaluate to 'True'. (https://rules.sonarsource.com/csharp/RSPEC-2589)
{
return Id;
}
Expand Down Expand Up @@ -300,7 +300,11 @@
internal void SetJsonPointerPath(string pointer, string nodeLocation)
{
// Relative reference to internal JSON schema node/resource (e.g. "#/properties/b")
if (pointer.StartsWith("#/", StringComparison.OrdinalIgnoreCase) && !pointer.Contains("/components/schemas"))
#if NETSTANDARD2_1 || NETCOREAPP2_1_OR_GREATER || NET5_0_OR_GREATER
if (pointer.StartsWith("#/", StringComparison.OrdinalIgnoreCase) && !pointer.Contains("/components/schemas", StringComparison.OrdinalIgnoreCase))
#else
if (pointer.StartsWith("#/", StringComparison.OrdinalIgnoreCase) && !pointer.ToLowerInvariant().Contains("/components/schemas"))
#endif
{
ReferenceV3 = ResolveRelativePointer(nodeLocation, pointer);
}
Expand All @@ -316,23 +320,28 @@
private static string ResolveRelativePointer(string nodeLocation, string relativeRef)
{
// Convert nodeLocation to path segments
var segments = nodeLocation.TrimStart('#').Split(['/'], StringSplitOptions.RemoveEmptyEntries).ToList();
var nodeLocationSegments = nodeLocation.TrimStart('#').Split(['/'], StringSplitOptions.RemoveEmptyEntries).ToList();

// Convert relativeRef to dynamic segments
var relativeSegments = relativeRef.TrimStart('#').Split(['/'], StringSplitOptions.RemoveEmptyEntries);

// Locate the first occurrence of relativeRef segments in the full path
for (int i = 0; i <= segments.Count - relativeSegments.Length; i++)
for (int i = 0; i <= nodeLocationSegments.Count - relativeSegments.Length; i++)
{
if (relativeSegments.SequenceEqual(segments.Skip(i).Take(relativeSegments.Length)))
if (relativeSegments.SequenceEqual(nodeLocationSegments.Skip(i).Take(relativeSegments.Length), StringComparer.Ordinal) &&
nodeLocationSegments.Take(i + relativeSegments.Length).ToArray() is {Length: > 0} matchingSegments)
{
// Trim to include just the matching segment chain
segments = [.. segments.Take(i + relativeSegments.Length)];
break;
return $"#/{string.Join("/", matchingSegments)}";
}
}

return $"#/{string.Join("/", segments)}";
// Fallback on building a full path
#if NETSTANDARD2_1 || NETCOREAPP2_1_OR_GREATER || NET5_0_OR_GREATER
return $"#/{string.Join("/", nodeLocationSegments.SkipLast(relativeSegments.Length).Union(relativeSegments))}";
#else
return $"#/{string.Join("/", nodeLocationSegments.Take(nodeLocationSegments.Count - relativeSegments.Length).Union(relativeSegments))}";
#endif
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"openapi": "3.1.0",
"info": {
"title": "Relative reference in a subschema of an component schema",
"version": "1.0.0"
},
"paths": {
"/items": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Foo"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Foo": {
"type": "object",
"properties": {
"seq1": {
"type": [
"array",
"null"
],
"items": {
"type": "array",
"items": {
"type": "string",
"format": null,
"x-schema-id": null
}
}
},
"seq2": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/properties/seq1/items"
}
}
},
"x-schema-id": "ContainerType"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -279,5 +279,22 @@ public void ResolveSubSchema_ShouldRecurseIntoAllOfComposition()
Assert.NotNull(result);
Assert.Equal(JsonSchemaType.Integer, result!.Type);
}
[Fact]
public async Task SHouldResolveRelativeSubReference()
{
// Arrange
var filePath = Path.Combine(SampleFolderPath, "relativeSubschemaReference.json");

// Act
var (actual, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings);

var fooComponentSchema = actual.Components.Schemas["Foo"];
var seq1Property = fooComponentSchema.Properties["seq1"];
Assert.NotNull(seq1Property);
var seq2Property = fooComponentSchema.Properties["seq2"];
Assert.NotNull(seq2Property);
Assert.Equal(JsonSchemaType.Array, seq2Property.Items.Type);
Assert.Equal(JsonSchemaType.String, seq2Property.Items.Items.Type);
}
}
}
Loading