Skip to content

Commit d757bfd

Browse files
authored
Resolve relative JSON schema references in root schema (#63256)
* Resolve relative references before processing * Add test coverage from reported issues * Clean up implementation * Fix up char-based checks * Remove visitedRefs and rely on STJ resolution
1 parent e6238c8 commit d757bfd

7 files changed

+1032
-12
lines changed

src/OpenApi/sample/Endpoints/MapSchemasEndpoints.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ public static IEndpointRouteBuilder MapSchemasEndpoints(this IEndpointRouteBuild
4141
schemas.MapPatch("/json-patch-generic", (JsonPatchDocument<ParentObject> patchDoc) => Results.NoContent());
4242
schemas.MapGet("/custom-iresult", () => new CustomIResultImplementor { Content = "Hello world!" })
4343
.Produces<CustomIResultImplementor>(200);
44+
45+
// Tests for validating scenarios related to https://github.com/dotnet/aspnetcore/issues/61194
46+
schemas.MapPost("/config-with-generic-lists", (Config config) => Results.Ok(config));
47+
schemas.MapPost("/project-response", (ProjectResponse project) => Results.Ok(project));
48+
schemas.MapPost("/subscription", (Subscription subscription) => Results.Ok(subscription));
4449
return endpointRouteBuilder;
4550
}
4651

@@ -111,4 +116,61 @@ public sealed class ChildObject
111116
public int Id { get; set; }
112117
public required ParentObject Parent { get; set; }
113118
}
119+
120+
// Example types for GitHub issue 61194: Generic types referenced multiple times
121+
public sealed class Config
122+
{
123+
public List<ConfigItem> Items1 { get; set; } = [];
124+
public List<ConfigItem> Items2 { get; set; } = [];
125+
}
126+
127+
public sealed class ConfigItem
128+
{
129+
public int? Id { get; set; }
130+
public string? Lang { get; set; }
131+
public Dictionary<string, object?>? Words { get; set; }
132+
public List<string>? Break { get; set; }
133+
public string? WillBeGood { get; set; }
134+
}
135+
136+
// Example types for GitHub issue 63054: Reused types across different hierarchies
137+
public sealed class ProjectResponse
138+
{
139+
public required ProjectAddressResponse Address { get; init; }
140+
public required ProjectBuilderResponse Builder { get; init; }
141+
}
142+
143+
public sealed class ProjectAddressResponse
144+
{
145+
public required CityResponse City { get; init; }
146+
}
147+
148+
public sealed class ProjectBuilderResponse
149+
{
150+
public required CityResponse City { get; init; }
151+
}
152+
153+
public sealed class CityResponse
154+
{
155+
public string Name { get; set; } = "";
156+
}
157+
158+
// Example types for GitHub issue 63211: Nullable reference types
159+
public sealed class Subscription
160+
{
161+
public required string Id { get; set; }
162+
public required RefProfile PrimaryUser { get; set; }
163+
public RefProfile? SecondaryUser { get; set; }
164+
}
165+
166+
public sealed class RefProfile
167+
{
168+
public required RefUser User { get; init; }
169+
}
170+
171+
public sealed class RefUser
172+
{
173+
public string Name { get; set; } = "";
174+
public string Email { get; set; } = "";
175+
}
114176
}

src/OpenApi/src/Services/OpenApiConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ internal static class OpenApiConstants
1515
internal const string RefId = "x-ref-id";
1616
internal const string RefDescriptionAnnotation = "x-ref-description";
1717
internal const string RefExampleAnnotation = "x-ref-example";
18+
internal const string RefKeyword = "$ref";
19+
internal const string RefPrefix = "#";
1820
internal const string DefaultOpenApiResponseKey = "default";
1921
// Since there's a finite set of HTTP methods that can be included in a given
2022
// OpenApiPaths, we can pre-allocate an array of these methods and use a direct

src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,5 +457,126 @@ private async Task InnerApplySchemaTransformersAsync(IOpenApiSchema inputSchema,
457457
}
458458

459459
private JsonNode CreateSchema(OpenApiSchemaKey key)
460-
=> JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration);
460+
{
461+
var schema = JsonSchemaExporter.GetJsonSchemaAsNode(_jsonSerializerOptions, key.Type, _configuration);
462+
return ResolveReferences(schema, schema);
463+
}
464+
465+
private static JsonNode ResolveReferences(JsonNode node, JsonNode rootSchema)
466+
{
467+
return ResolveReferencesRecursive(node, rootSchema);
468+
}
469+
470+
private static JsonNode ResolveReferencesRecursive(JsonNode node, JsonNode rootSchema)
471+
{
472+
if (node is JsonObject jsonObject)
473+
{
474+
if (jsonObject.TryGetPropertyValue(OpenApiConstants.RefKeyword, out var refNode) &&
475+
refNode is JsonValue refValue &&
476+
refValue.TryGetValue<string>(out var refString) &&
477+
refString.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal))
478+
{
479+
try
480+
{
481+
// Resolve the reference path to the actual schema content
482+
// to avoid relative references
483+
var resolvedNode = ResolveReference(refString, rootSchema);
484+
if (resolvedNode != null)
485+
{
486+
return resolvedNode.DeepClone();
487+
}
488+
}
489+
catch (InvalidOperationException)
490+
{
491+
// If resolution fails due to invalid path, return the original reference
492+
// This maintains backward compatibility while preventing crashes
493+
}
494+
495+
// If resolution fails, return the original reference
496+
return node;
497+
}
498+
499+
// Process all properties recursively
500+
var newObject = new JsonObject();
501+
foreach (var property in jsonObject)
502+
{
503+
if (property.Value != null)
504+
{
505+
var processedValue = ResolveReferencesRecursive(property.Value, rootSchema);
506+
newObject[property.Key] = processedValue?.DeepClone();
507+
}
508+
else
509+
{
510+
newObject[property.Key] = null;
511+
}
512+
}
513+
return newObject;
514+
}
515+
else if (node is JsonArray jsonArray)
516+
{
517+
var newArray = new JsonArray();
518+
for (var i = 0; i < jsonArray.Count; i++)
519+
{
520+
if (jsonArray[i] != null)
521+
{
522+
var processedValue = ResolveReferencesRecursive(jsonArray[i]!, rootSchema);
523+
newArray.Add(processedValue?.DeepClone());
524+
}
525+
else
526+
{
527+
newArray.Add(null);
528+
}
529+
}
530+
return newArray;
531+
}
532+
533+
// Return non-$ref nodes as-is
534+
return node;
535+
}
536+
537+
private static JsonNode? ResolveReference(string refPath, JsonNode rootSchema)
538+
{
539+
if (string.IsNullOrWhiteSpace(refPath))
540+
{
541+
throw new InvalidOperationException("Reference path cannot be null or empty.");
542+
}
543+
544+
if (!refPath.StartsWith(OpenApiConstants.RefPrefix, StringComparison.Ordinal))
545+
{
546+
throw new InvalidOperationException($"Only fragment references (starting with '{OpenApiConstants.RefPrefix}') are supported. Found: {refPath}");
547+
}
548+
549+
var path = refPath.TrimStart('#', '/');
550+
if (string.IsNullOrEmpty(path))
551+
{
552+
return rootSchema;
553+
}
554+
555+
var segments = path.Split('/');
556+
var current = rootSchema;
557+
558+
for (var i = 0; i < segments.Length; i++)
559+
{
560+
var segment = segments[i];
561+
if (current is JsonObject currentObject)
562+
{
563+
if (currentObject.TryGetPropertyValue(segment, out var nextNode) && nextNode != null)
564+
{
565+
current = nextNode;
566+
}
567+
else
568+
{
569+
var partialPath = string.Join('/', segments.Take(i + 1));
570+
throw new InvalidOperationException($"Failed to resolve reference '{refPath}': path segment '{segment}' not found at '#{partialPath}'");
571+
}
572+
}
573+
else
574+
{
575+
var partialPath = string.Join('/', segments.Take(i));
576+
throw new InvalidOperationException($"Failed to resolve reference '{refPath}': cannot navigate beyond '#{partialPath}' - expected object but found {current?.GetType().Name ?? "null"}");
577+
}
578+
}
579+
580+
return current;
581+
}
461582
}

0 commit comments

Comments
 (0)