Skip to content

Commit 486ee28

Browse files
committed
chore: adds recursion for resolving nested subschemas
1 parent 0212484 commit 486ee28

File tree

7 files changed

+232
-8
lines changed

7 files changed

+232
-8
lines changed

src/Microsoft.OpenApi/Models/OpenApiDocument.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,17 +587,59 @@ private static string ConvertByteArrayToString(byte[] hash)
587587
}
588588
else
589589
{
590-
string relativePath = $"#{OpenApiConstants.ComponentsSegment}{reference.Type.GetDisplayName()}/{id}";
590+
string relativePath;
591+
592+
if (!string.IsNullOrEmpty(reference.ReferenceV3) && IsSubComponent(reference.ReferenceV3!))
593+
{
594+
// Enables setting the complete JSON path for nested subschemas e.g. #/components/schemas/person/properties/address
595+
if (useExternal)
596+
{
597+
var relPathSegment = reference.ReferenceV3!.Split('#')[1];
598+
relativePath = $"#{relPathSegment}";
599+
}
600+
else
601+
{
602+
relativePath = reference.ReferenceV3!;
603+
}
604+
}
605+
else
606+
{
607+
relativePath = $"#{OpenApiConstants.ComponentsSegment}{reference.Type.GetDisplayName()}/{id}";
608+
}
609+
591610
Uri? externalResourceUri = useExternal ? Workspace?.GetDocumentId(reference.ExternalResource) : null;
592611

593612
uriLocation = useExternal && externalResourceUri is not null
594613
? externalResourceUri.AbsoluteUri + relativePath
595614
: BaseUri + relativePath;
596615
}
597616

617+
if (reference.Type is ReferenceType.Schema && !uriLocation.StartsWith("http", StringComparison.OrdinalIgnoreCase))
618+
{
619+
return Workspace?.ResolveJsonSchemaReference(new Uri(uriLocation).AbsoluteUri);
620+
}
621+
598622
return Workspace?.ResolveReference<IOpenApiReferenceable>(new Uri(uriLocation).AbsoluteUri);
599623
}
600624

625+
private static bool IsSubComponent(string reference)
626+
{
627+
// Normalize fragment part only
628+
var parts = reference.Split('#');
629+
var fragment = parts.Length > 1 ? parts[1] : string.Empty;
630+
631+
if (fragment.StartsWith("/components/schemas/", StringComparison.OrdinalIgnoreCase))
632+
{
633+
var segments = fragment.Split('/');
634+
635+
// Expect exactly 4 segments for root-level schema: ["", "components", "schemas", "person"]
636+
// Anything longer means it's a subcomponent.
637+
return segments.Length > 4;
638+
}
639+
640+
return false;
641+
}
642+
601643
/// <summary>
602644
/// Reads the stream input and parses it into an Open API document.
603645
/// </summary>

src/Microsoft.OpenApi/Models/OpenApiReference.cs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,22 @@ public class OpenApiReference : IOpenApiSerializable, IOpenApiDescribedElement,
7373
/// </summary>
7474
public OpenApiDocument? HostDocument { get => hostDocument; init => hostDocument = value; }
7575

76+
private string? _referenceV3;
7677
/// <summary>
7778
/// Gets the full reference string for v3.0.
7879
/// </summary>
7980
public string? ReferenceV3
8081
{
8182
get
8283
{
84+
if (!string.IsNullOrEmpty(_referenceV3))
85+
{
86+
return _referenceV3;
87+
}
88+
8389
if (IsExternal)
8490
{
85-
return GetExternalReferenceV3();
91+
return _referenceV3 = GetExternalReferenceV3();
8692
}
8793

8894
if (Type == ReferenceType.Tag)
@@ -100,7 +106,14 @@ public string? ReferenceV3
100106
return Id;
101107
}
102108

103-
return "#/components/" + Type.GetDisplayName() + "/" + Id;
109+
return _referenceV3 = "#/components/" + Type.GetDisplayName() + "/" + Id;
110+
}
111+
set
112+
{
113+
if (value is not null)
114+
{
115+
_referenceV3 = value;
116+
}
104117
}
105118
}
106119

@@ -299,5 +312,15 @@ internal void SetSummaryAndDescriptionFromMapNode(MapNode mapNode)
299312
Summary = summary;
300313
}
301314
}
315+
316+
internal void SetJsonPointerPath(string pointer)
317+
{
318+
// Eg of an internal subcomponent's JSONPath: #/components/schemas/person/properties/address
319+
if ((pointer.Contains("#") || pointer.StartsWith("http", StringComparison.OrdinalIgnoreCase))
320+
&& !string.IsNullOrEmpty(ReferenceV3) && !ReferenceV3!.Equals(pointer, StringComparison.OrdinalIgnoreCase))
321+
{
322+
ReferenceV3 = pointer;
323+
}
324+
}
302325
}
303326
}

src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

44
using Microsoft.OpenApi.Extensions;
@@ -370,8 +370,9 @@ public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocu
370370
var reference = GetReferenceIdAndExternalResource(pointer);
371371
var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
372372
result.Reference.SetSummaryAndDescriptionFromMapNode(mapNode);
373+
result.Reference.SetJsonPointerPath(pointer);
373374
return result;
374-
}
375+
}
375376

376377
var schema = new OpenApiSchema();
377378

@@ -400,7 +401,7 @@ public static IOpenApiSchema LoadSchema(ParseNode node, OpenApiDocument hostDocu
400401

401402
if (identifier is not null && hostDocument.Workspace is not null)
402403
{
403-
// register the schema in our registry using the identifer's URL
404+
// register the schema in our registry using the identifier's URL
404405
hostDocument.Workspace.RegisterComponentForDocument(hostDocument, schema, identifier);
405406
}
406407

src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.IO;
7+
using System.Linq;
78
using Microsoft.OpenApi.Extensions;
89
using Microsoft.OpenApi.Interfaces;
910
using Microsoft.OpenApi.Models;
@@ -330,6 +331,90 @@ public bool Contains(string location)
330331
return default;
331332
}
332333

334+
/// <summary>
335+
/// Recursively resolves a schema from a URI fragment.
336+
/// </summary>
337+
/// <param name="location"></param>
338+
/// <returns></returns>
339+
public IOpenApiSchema? ResolveJsonSchemaReference(string location)
340+
{
341+
/* Enables resolving references for nested subschemas
342+
* Examples:
343+
* #/components/schemas/person/properties/address"
344+
* #/components/schemas/human/allOf/0
345+
*/
346+
347+
if (string.IsNullOrEmpty(location)) return default;
348+
349+
var uri = ToLocationUrl(location);
350+
string[] pathSegments;
351+
352+
if (uri is not null)
353+
{
354+
pathSegments = uri.Fragment.Split(['/'], StringSplitOptions.RemoveEmptyEntries);
355+
356+
// Build the base path for the root schema: "#/components/schemas/person"
357+
var fragment = OpenApiConstants.ComponentsSegment + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + pathSegments[3];
358+
var uriBuilder = new UriBuilder(uri)
359+
{
360+
Fragment = fragment
361+
}; // to avoid escaping the # character in the resulting Uri
362+
363+
if (_IOpenApiReferenceableRegistry.TryGetValue(uriBuilder.Uri, out var schema) && schema is OpenApiSchema targetSchema)
364+
{
365+
// traverse remaining segments after fetching the base schema
366+
var remainingSegments = pathSegments.Skip(4).ToArray();
367+
return ResolveSubSchema(targetSchema, remainingSegments);
368+
}
369+
}
370+
371+
return default;
372+
}
373+
374+
private static IOpenApiSchema? ResolveSubSchema(IOpenApiSchema schema, string[] pathSegments)
375+
{
376+
// Traverse schema object to resolve subschemas
377+
if (pathSegments.Length == 0)
378+
{
379+
return schema;
380+
}
381+
var currentSegment = pathSegments[0];
382+
pathSegments = [.. pathSegments.Skip(1)]; // skip one segment for the next recursive call
383+
384+
switch (currentSegment)
385+
{
386+
case OpenApiConstants.Properties:
387+
var propName = pathSegments[0];
388+
if (schema.Properties != null && schema.Properties.TryGetValue(propName, out var propSchema))
389+
return ResolveSubSchema(propSchema, [.. pathSegments.Skip(1)]);
390+
break;
391+
case OpenApiConstants.Items:
392+
return schema.Items is OpenApiSchema itemsSchema ? ResolveSubSchema(itemsSchema, pathSegments) : null;
393+
394+
case OpenApiConstants.AdditionalProperties:
395+
return schema.AdditionalProperties is OpenApiSchema additionalSchema ? ResolveSubSchema(additionalSchema, pathSegments) : null;
396+
case OpenApiConstants.AllOf:
397+
case OpenApiConstants.AnyOf:
398+
case OpenApiConstants.OneOf:
399+
if (!int.TryParse(pathSegments[0], out var index)) return null;
400+
401+
var list = currentSegment switch
402+
{
403+
OpenApiConstants.AllOf => schema.AllOf,
404+
OpenApiConstants.AnyOf => schema.AnyOf,
405+
OpenApiConstants.OneOf => schema.OneOf,
406+
_ => null
407+
};
408+
409+
// recurse into the indexed subschema if valid
410+
if (list != null && index < list.Count)
411+
return ResolveSubSchema(list[index], [.. pathSegments.Skip(1)]);
412+
break;
413+
}
414+
415+
return null;
416+
}
417+
333418
private Uri? ToLocationUrl(string location)
334419
{
335420
if (BaseUrl is not null)

test/Microsoft.OpenApi.Readers.Tests/V31Tests/ReferenceSamples/internalComponentsSubschemaReference.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,24 @@ paths:
2121
application/json:
2222
schema:
2323
$ref: '#/components/schemas/person/properties/address'
24+
/human:
25+
get:
26+
responses:
27+
200:
28+
description: ok
29+
content:
30+
application/json:
31+
schema:
32+
$ref: '#/components/schemas/human/allOf/0'
2433
components:
2534
schemas:
35+
human:
36+
allOf:
37+
- $ref: '#/components/schemas/person/items'
38+
- type: object
39+
properties:
40+
name:
41+
type: string
2642
person:
2743
type: object
2844
properties:
@@ -34,4 +50,6 @@ components:
3450
street:
3551
type: string
3652
city:
37-
type: string
53+
type: string
54+
items:
55+
type: integer

test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,42 @@ public async Task ParseSubschemaComponentJsonSchemaReferenceWorks()
136136
Assert.Equal(JsonSchemaType.Object, schema.Type);
137137
}
138138

139+
[Fact]
140+
public async Task ParseInternalComponentSubschemaJsonSchemaReferenceWorks()
141+
{
142+
// Arrange
143+
var filePath = Path.Combine(SampleFolderPath, "internalComponentsSubschemaReference.yaml");
144+
145+
// Act
146+
var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document;
147+
var addressSchema = actual.Paths["/person/{id}/address"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema;
148+
var itemsSchema = actual.Paths["/human"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema;
149+
150+
// Assert
151+
Assert.Equal(JsonSchemaType.Object, addressSchema.Type);
152+
Assert.Equal(JsonSchemaType.Integer, itemsSchema.Type);
153+
}
154+
155+
[Fact]
156+
public async Task ParseExternalComponentSubschemaJsonSchemaReferenceWorks()
157+
{
158+
// Arrange
159+
var path = Path.Combine(Directory.GetCurrentDirectory(), SampleFolderPath);
160+
var settings = new OpenApiReaderSettings
161+
{
162+
LoadExternalRefs = true,
163+
BaseUrl = new(path),
164+
};
165+
settings.AddYamlReader();
166+
167+
// Act
168+
var actual = (await OpenApiDocument.LoadAsync(Path.Combine(SampleFolderPath, "externalComponentSubschemaReference.yaml"), settings)).Document;
169+
var schema = actual.Paths["/person/{id}"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema;
170+
171+
// Assert
172+
Assert.Equal(JsonSchemaType.Object, schema.Type);
173+
}
174+
139175
[Fact]
140176
public async Task ParseReferenceToInternalComponentUsingDollarIdWorks()
141177
{
@@ -149,5 +185,23 @@ public async Task ParseReferenceToInternalComponentUsingDollarIdWorks()
149185
// Assert
150186
Assert.Equal(JsonSchemaType.Object, schema.Type);
151187
}
188+
189+
[Fact]
190+
public async Task ParseLocalReferenceToJsonSchemaResourceWorks()
191+
{
192+
// Arrange
193+
var filePath = Path.Combine(SampleFolderPath, "localReferenceToJsonSchemaResource.yaml");
194+
var stringWriter = new StringWriter();
195+
var writer = new OpenApiYamlWriter(stringWriter);
196+
197+
// Act
198+
var actual = (await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings)).Document;
199+
var schema = actual.Components.Schemas["a"].Properties["b"].Properties["c"].Properties["b"];
200+
schema.SerializeAsV31(writer);
201+
var content = stringWriter.ToString();
202+
203+
// Assert
204+
Assert.Equal(JsonSchemaType.Object | JsonSchemaType.Null, schema.Type);
205+
}
152206
}
153207
}

test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ namespace Microsoft.OpenApi.Models
965965
public bool IsFragment { get; init; }
966966
public bool IsLocal { get; }
967967
public string? ReferenceV2 { get; }
968-
public string? ReferenceV3 { get; }
968+
public string? ReferenceV3 { get; set; }
969969
public string? Summary { get; set; }
970970
public Microsoft.OpenApi.Models.ReferenceType Type { get; init; }
971971
public void SerializeAsV2(Microsoft.OpenApi.Writers.IOpenApiWriter writer) { }
@@ -1650,6 +1650,7 @@ namespace Microsoft.OpenApi.Services
16501650
public System.Uri? GetDocumentId(string? key) { }
16511651
public bool RegisterComponentForDocument<T>(Microsoft.OpenApi.Models.OpenApiDocument openApiDocument, T componentToRegister, string id) { }
16521652
public void RegisterComponents(Microsoft.OpenApi.Models.OpenApiDocument document) { }
1653+
public Microsoft.OpenApi.Models.Interfaces.IOpenApiSchema? ResolveJsonSchemaReference(string location) { }
16531654
public T? ResolveReference<T>(string location) { }
16541655
}
16551656
public class OperationSearch : Microsoft.OpenApi.Services.OpenApiVisitorBase

0 commit comments

Comments
 (0)