Skip to content

Resolve relative JSON schema references in root schema #63256

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 5 commits into from
Aug 14, 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
62 changes: 62 additions & 0 deletions src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild
schemas.MapPatch("/json-patch-generic", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());
schemas.MapGet("/custom-iresult", () => new CustomIResultImplementor { Content = "Hello world!" })
.Produces<CustomIResultImplementor>(200);

// Tests for validating scenarios related to https://github.com/dotnet/aspnetcore/issues/61194
schemas.MapPost("/config-with-generic-lists", (Config config) => Results.Ok(config));
schemas.MapPost("/project-response", (ProjectResponse project) => Results.Ok(project));
schemas.MapPost("/subscription", (Subscription subscription) => Results.Ok(subscription));
return endpointRouteBuilder;
}

Expand Down Expand Up @@ -111,4 +116,61 @@ public sealed class ChildObject
public int Id { get; set; }
public required ParentObject Parent { get; set; }
}

// Example types for GitHub issue 61194: Generic types referenced multiple times
public sealed class Config
{
public List<ConfigItem> Items1 { get; set; } = [];
public List<ConfigItem> Items2 { get; set; } = [];
}

public sealed class ConfigItem
{
public int? Id { get; set; }
public string? Lang { get; set; }
public Dictionary<string, object?>? Words { get; set; }
public List<string>? Break { get; set; }
public string? WillBeGood { get; set; }
}

// Example types for GitHub issue 63054: Reused types across different hierarchies
public sealed class ProjectResponse
{
public required ProjectAddressResponse Address { get; init; }
public required ProjectBuilderResponse Builder { get; init; }
}

public sealed class ProjectAddressResponse
{
public required CityResponse City { get; init; }
}

public sealed class ProjectBuilderResponse
{
public required CityResponse City { get; init; }
}

public sealed class CityResponse
{
public string Name { get; set; } = "";
}

// Example types for GitHub issue 63211: Nullable reference types
public sealed class Subscription
{
public required string Id { get; set; }
public required RefProfile PrimaryUser { get; set; }
public RefProfile? SecondaryUser { get; set; }
}

public sealed class RefProfile
{
public required RefUser User { get; init; }
}

public sealed class RefUser
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
}
2 changes: 2 additions & 0 deletions src/OpenApi/src/Services/OpenApiConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ internal static class OpenApiConstants
internal const string RefId = "x-ref-id";
internal const string RefDescriptionAnnotation = "x-ref-description";
internal const string RefExampleAnnotation = "x-ref-example";
internal const string RefKeyword = "$ref";
internal const string RefPrefix = "#";
internal const string DefaultOpenApiResponseKey = "default";
// Since there's a finite set of HTTP methods that can be included in a given
// OpenApiPaths, we can pre-allocate an array of these methods and use a direct
Expand Down
123 changes: 122 additions & 1 deletion src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -457,5 +457,126 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema,
}

private JsonNode CreateSchema(OpenApiSchemaKey key)
=> JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration);
{
var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration);
return ResolveReferences(schema, schema);
}

private static JsonNode ResolveReferences(JsonNode node, JsonNode rootSchema)
{
return ResolveReferencesRecursive(node, rootSchema);
}

private static JsonNode ResolveReferencesRecursive(JsonNode node, JsonNode rootSchema)
{
if (node is JsonObject jsonObject)
{
if (jsonObject.TryGetPropertyValue(OpenApiConstants.RefKeyword, out var refNode) &&
refNode is JsonValue refValue &&
refValue.TryGetValue<string>(out var refString) &&
refString.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal))
{
try
{
// Resolve the reference path to the actual schema content
// to avoid relative references
var resolvedNode = ResolveReference(refString, rootSchema);
if (resolvedNode != null)
{
return resolvedNode.DeepClone();
}
}
catch (InvalidOperationException)
{
// If resolution fails due to invalid path, return the original reference
// This maintains backward compatibility while preventing crashes
}

// If resolution fails, return the original reference
return node;
}

// Process all properties recursively
var newObject = new JsonObject();
foreach (var property in jsonObject)
{
if (property.Value != null)
{
var processedValue = ResolveReferencesRecursive(property.Value, rootSchema);
newObject[property.Key] = processedValue?.DeepClone();
}
else
{
newObject[property.Key] = null;
}
}
return newObject;
}
else if (node is JsonArray jsonArray)
{
var newArray = new JsonArray();
for (var i = 0; i < jsonArray.Count; i++)
{
if (jsonArray[i] != null)
{
var processedValue = ResolveReferencesRecursive(jsonArray[i]!, rootSchema);
newArray.Add(processedValue?.DeepClone());
}
else
{
newArray.Add(null);
}
}
return newArray;
}

// Return non-$ref nodes as-is
return node;
}

private static JsonNode? ResolveReference(string refPath, JsonNode rootSchema)
{
if (string.IsNullOrWhiteSpace(refPath))
{
throw new InvalidOperationException("Reference path cannot be null or empty.");
}

if (!refPath.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Only fragment references (starting with '{OpenApiConstants.RefPrefix}') are supported. Found: {refPath}");
}

var path = refPath.TrimStart('#', '/');
if (string.IsNullOrEmpty(path))
{
return rootSchema;
}

var segments = path.Split('/');
var current = rootSchema;

for (var i = 0; i < segments.Length; i++)
{
var segment = segments[i];
if (current is JsonObject currentObject)
{
if (currentObject.TryGetPropertyValue(segment, out var nextNode) && nextNode != null)
{
current = nextNode;
}
else
{
var partialPath = string.Join('/', segments.Take(i + 1));
throw new InvalidOperationException($"Failed to resolve reference '{refPath}': path segment '{segment}' not found at '#{partialPath}'");
}
}
else
{
var partialPath = string.Join('/', segments.Take(i));
throw new InvalidOperationException($"Failed to resolve reference '{refPath}': cannot navigate beyond '#{partialPath}' - expected object but found {current?.GetType().Name ?? "null"}");
}
}

return current;
}
}
Loading
Loading