Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
135 changes: 134 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,138 @@ 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, HashSet<string> visitedRefs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need something to stop an infinite loop happening? IIRC, there's other bits of the code that do recursion that do something like "this is the 64th time I'm calling this, so that's too much - stop".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For handling recursion, we rely on things being caught by JsonSchemaExporter before the schema is returned to us. Tests like ThrowsForOverlyNestedSchemas and SupportsDeeplyNestedSchemaWithConfiguredMaxDepth cover this. I don't think there's any new recursion issues that get introduced here and the logic for tracking visited refs is probably sufficient for this scenario?

{
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))
{
if (visitedRefs.Contains(refString))
{
return node;
}

visitedRefs.Add(refString);

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
}
finally
{
// Remove from visited set to allow the same reference in different branches
visitedRefs.Remove(refString);
}

// 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, visitedRefs);
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, visitedRefs);
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