Skip to content

chore: implement test cases for types of References in an OpenApi document #2352

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 13 commits into from
Jun 2, 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
45 changes: 44 additions & 1 deletion src/Microsoft.OpenApi/Models/OpenApiDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -581,17 +581,60 @@ private static string ConvertByteArrayToString(byte[] hash)
}
else
{
string relativePath = $"#{OpenApiConstants.ComponentsSegment}{reference.Type.GetDisplayName()}/{id}";
string relativePath;
var referenceV3 = !string.IsNullOrEmpty(reference.ReferenceV3) ? reference.ReferenceV3! : string.Empty;

if (!string.IsNullOrEmpty(referenceV3) && IsSubComponent(referenceV3))
{
// Enables setting the complete JSON path for nested subschemas e.g. #/components/schemas/person/properties/address
if (useExternal)
{
var relPathSegment = referenceV3.Split(['#'], StringSplitOptions.RemoveEmptyEntries)[1];
relativePath = $"#{relPathSegment}";
}
else
{
relativePath = referenceV3;
}
}
else
{
relativePath = $"#{OpenApiConstants.ComponentsSegment}{reference.Type.GetDisplayName()}/{id}";
}

Uri? externalResourceUri = useExternal ? Workspace?.GetDocumentId(reference.ExternalResource) : null;

uriLocation = useExternal && externalResourceUri is not null
? externalResourceUri.AbsoluteUri + relativePath
: BaseUri + relativePath;
}

if (reference.Type is ReferenceType.Schema && uriLocation.Contains('#'))
{
return Workspace?.ResolveJsonSchemaReference(new Uri(uriLocation).AbsoluteUri);
}

return Workspace?.ResolveReference<IOpenApiReferenceable>(new Uri(uriLocation).AbsoluteUri);
}

private static bool IsSubComponent(string reference)
{
// Normalize fragment part only
var parts = reference.Split('#');
var fragment = parts.Length > 1 ? parts[1] : string.Empty;

if (fragment.StartsWith("/components/schemas/", StringComparison.OrdinalIgnoreCase))
{
var segments = fragment.Split(['/'], StringSplitOptions.RemoveEmptyEntries);

// Expect exactly 3 segments for root-level schema: ["components", "schemas", "person"]
// Anything longer means it's a subcomponent.
return segments.Length > 3;
}

return false;
}

/// <summary>
/// Reads the stream input and parses it into an Open API document.
/// </summary>
Expand Down
56 changes: 54 additions & 2 deletions src/Microsoft.OpenApi/Models/OpenApiReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

using System;
using System.Linq;
using System.Text.Json.Nodes;
using Microsoft.OpenApi.Reader;

Expand Down Expand Up @@ -67,15 +68,21 @@
/// <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 71 in src/Microsoft.OpenApi/Models/OpenApiReference.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>
/// Gets the full reference string for v3.0.
/// </summary>
public string? ReferenceV3
{
get
{
if (!string.IsNullOrEmpty(_referenceV3))
{
return _referenceV3;
}

if (IsExternal)
{
return GetExternalReferenceV3();
Expand All @@ -90,13 +97,20 @@
{
return Id;
}
if (!string.IsNullOrEmpty(Id) && Id is not null && Id.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||

Check warning on line 100 in src/Microsoft.OpenApi/Models/OpenApiReference.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 101 in src/Microsoft.OpenApi/Models/OpenApiReference.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;
}

return "#/components/" + Type.GetDisplayName() + "/" + Id;
return $"#/components/{Type.GetDisplayName()}/{Id}";
}
private set
{
if (value is not null)
{
_referenceV3 = value;
}
}
}

Expand All @@ -122,7 +136,7 @@
return Id;
}

return "#/" + GetReferenceTypeNameAsV2(Type) + "/" + Id;
return $"#/{GetReferenceTypeNameAsV2(Type)}/{Id}";
}
}

Expand Down Expand Up @@ -295,5 +309,43 @@
Summary = summary;
}
}

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"))
{
ReferenceV3 = ResolveRelativePointer(nodeLocation, pointer);
}

// Absolute reference or anchor (e.g. "#/components/schemas/..." or full URL)
else if ((pointer.Contains('#') || pointer.StartsWith("http", StringComparison.OrdinalIgnoreCase))
&& !string.Equals(ReferenceV3, pointer, StringComparison.OrdinalIgnoreCase))
{
ReferenceV3 = pointer;
}
}

private static string ResolveRelativePointer(string nodeLocation, string relativeRef)
{
// Convert nodeLocation to path segments
var segments = 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++)
{
if (relativeSegments.SequenceEqual(segments.Skip(i).Take(relativeSegments.Length)))
{
// Trim to include just the matching segment chain
segments = [.. segments.Take(i + relativeSegments.Length)];
break;
}
}

return $"#/{string.Join("/", segments)}";
}
}
}
10 changes: 10 additions & 0 deletions src/Microsoft.OpenApi/Reader/ParseNodes/MapNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
}

_node = mapNode;
_nodes = _node.Where(p => p.Value is not null).OfType<KeyValuePair<string, JsonNode>>().Select(p => new PropertyNode(Context, p.Key, p.Value)).ToList();

Check warning on line 32 in src/Microsoft.OpenApi/Reader/ParseNodes/MapNode.cs

View workflow job for this annotation

GitHub Actions / Build

Remove this unnecessary cast to 'IEnumerable<KeyValuePair<string, JsonNode>>'. (https://rules.sonarsource.com/csharp/RSPEC-1905)
}

public PropertyNode? this[string key]
Expand Down Expand Up @@ -153,6 +153,16 @@
return refNode?.GetScalarValue();
}

public string? GetJsonSchemaIdentifier()
{
if (!_node.TryGetPropertyValue("$id", out JsonNode? idNode))
{
return null;
}

return idNode?.GetScalarValue();
}

public string? GetSummaryValue()
{
if (!_node.TryGetPropertyValue("summary", out JsonNode? summaryNode))
Expand Down Expand Up @@ -182,7 +192,7 @@
? jsonValue
: throw new OpenApiReaderException($"Expected scalar while parsing {key.GetScalarValue()}", Context);

return Convert.ToString(scalarNode?.GetValue<object>(), CultureInfo.InvariantCulture);

Check warning on line 195 in src/Microsoft.OpenApi/Reader/ParseNodes/MapNode.cs

View workflow job for this annotation

GitHub Actions / Build

Remove this unnecessary check for null. (https://rules.sonarsource.com/csharp/RSPEC-2589)
}
return null;
}
Expand Down
11 changes: 10 additions & 1 deletion src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{
internal static partial class OpenApiV31Deserializer
{
private static readonly FixedFieldMap<OpenApiSchema> _openApiSchemaFixedFields = new()

Check warning on line 14 in src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this field to reduce its Cognitive Complexity from 57 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
{
"title",
Expand Down Expand Up @@ -198,7 +198,7 @@
{
var list = n.CreateSimpleList((n2, p) => n2.GetScalarValue(), doc);
JsonSchemaType combinedType = 0;
foreach(var type in list)

Check warning on line 201 in src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs

View workflow job for this annotation

GitHub Actions / Build

Loops should be simplified using the "Where" LINQ method (https://rules.sonarsource.com/csharp/RSPEC-3267)
{
if (type is not null)
{
Expand Down Expand Up @@ -361,14 +361,17 @@
var mapNode = node.CheckMapNode(OpenApiConstants.Schema);

var pointer = mapNode.GetReferencePointer();
var identifier = mapNode.GetJsonSchemaIdentifier();
var nodeLocation = node.Context.GetLocation();

if (pointer != null)
{
var reference = GetReferenceIdAndExternalResource(pointer);
var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
result.Reference.SetSummaryAndDescriptionFromMapNode(mapNode);
result.Reference.SetJsonPointerPath(pointer, nodeLocation);
return result;
}
}

var schema = new OpenApiSchema();

Expand Down Expand Up @@ -398,6 +401,12 @@
schema.Extensions.Remove(OpenApiConstants.NullableExtension);
}

if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null)
{
// register the schema in our registry using the identifier's URL
hostDocument.Workspace.RegisterComponentForDocument(hostDocument, schema, identifier!);
}

return schema;
}
}
Expand Down
85 changes: 85 additions & 0 deletions src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Microsoft.OpenApi
{
Expand Down Expand Up @@ -80,7 +81,7 @@
/// Registers a document's components into the workspace
/// </summary>
/// <param name="document"></param>
public void RegisterComponents(OpenApiDocument document)

Check warning on line 84 in src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
if (document?.Components == null) return;

Expand Down Expand Up @@ -267,7 +268,7 @@
/// <param name="value"></param>
public void AddDocumentId(string? key, Uri? value)
{
if (!string.IsNullOrEmpty(key) && key is not null && value is not null && !_documentsIdRegistry.ContainsKey(key))

Check warning on line 271 in src/Microsoft.OpenApi/Services/OpenApiWorkspace.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)
{
_documentsIdRegistry[key] = value;
}
Expand Down Expand Up @@ -326,6 +327,90 @@
return default;
}

/// <summary>
/// Recursively resolves a schema from a URI fragment.
/// </summary>
/// <param name="location"></param>
/// <returns></returns>
internal IOpenApiSchema? ResolveJsonSchemaReference(string location)
{
/* Enables resolving references for nested subschemas
* Examples:
* #/components/schemas/person/properties/address"
* #/components/schemas/human/allOf/0
*/

if (string.IsNullOrEmpty(location)) return default;

var uri = ToLocationUrl(location);
string[] pathSegments;

if (uri is not null)
{
pathSegments = uri.Fragment.Split(['/'], StringSplitOptions.RemoveEmptyEntries);

// Build the base path for the root schema: "#/components/schemas/person"
var fragment = OpenApiConstants.ComponentsSegment + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + pathSegments[3];
var uriBuilder = new UriBuilder(uri)
{
Fragment = fragment
}; // to avoid escaping the # character in the resulting Uri

if (_IOpenApiReferenceableRegistry.TryGetValue(uriBuilder.Uri, out var schema) && schema is IOpenApiSchema targetSchema)
{
// traverse remaining segments after fetching the base schema
var remainingSegments = pathSegments.Skip(4).ToArray();
return ResolveSubSchema(targetSchema, remainingSegments);
}
}

return default;
}

internal static IOpenApiSchema? ResolveSubSchema(IOpenApiSchema schema, string[] pathSegments)

Check warning on line 370 in src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
// Traverse schema object to resolve subschemas
if (pathSegments.Length == 0)
{
return schema;
}
var currentSegment = pathSegments[0];
pathSegments = [.. pathSegments.Skip(1)]; // skip one segment for the next recursive call

switch (currentSegment)
{
case OpenApiConstants.Properties:
var propName = pathSegments[0];
if (schema.Properties != null && schema.Properties.TryGetValue(propName, out var propSchema))
return ResolveSubSchema(propSchema, [.. pathSegments.Skip(1)]);
break;
case OpenApiConstants.Items:
return schema.Items is OpenApiSchema itemsSchema ? ResolveSubSchema(itemsSchema, pathSegments) : null;

case OpenApiConstants.AdditionalProperties:
return schema.AdditionalProperties is OpenApiSchema additionalSchema ? ResolveSubSchema(additionalSchema, pathSegments) : null;
case OpenApiConstants.AllOf:
case OpenApiConstants.AnyOf:
case OpenApiConstants.OneOf:
if (!int.TryParse(pathSegments[0], out var index)) return null;

var list = currentSegment switch
{
OpenApiConstants.AllOf => schema.AllOf,
OpenApiConstants.AnyOf => schema.AnyOf,
OpenApiConstants.OneOf => schema.OneOf,
_ => null
};

// recurse into the indexed subschema if valid
if (list != null && index < list.Count)
return ResolveSubSchema(list[index], [.. pathSegments.Skip(1)]);
break;
}

return null;
}

private Uri? ToLocationUrl(string location)
{
if (BaseUrl is not null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
openapi: 3.1.0
info:
title: OpenAPI document containing reusable components
version: 1.0.0
components:
schemas:
person:
type: object
properties:
name:
type: string
address:
type: object
properties:
street:
type: string
city:
type: string
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
openapi: 3.1.0
info:
title: Example of reference object in a component object
version: 1.0.0
paths:
/item:
get:
security:
- customapikey: []
components:
securitySchemes:
customapikey:
$ref: ./customApiKey.yaml#/components/securityschemes/customapikey
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
openapi: 3.1.0
info:
title: Example of reference object pointing to a parameter
version: 1.0.0
paths: {}
components:
securitySchemes:
customapikey:
type: apiKey
name: x-api-key
in: header
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# file for examples (examples.yaml)
openapi: 3.1.0
info:
title: OpenAPI document containing examples for reuse
version: 1.0.0
components:
examples:
item-list:
value:
- name: thing
description: a thing
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
openapi: 3.1.0
info:
title: Reference to an external OpenApi document component
version: 1.0.0
paths:
/person/{id}:
get:
responses:
200:
description: ok
content:
application/json:
schema:
$ref: 'OAS-schemas.yaml#/components/schemas/person/properties/address'
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
openapi: 3.1.0
info:
title: Example of reference object pointing to an example object in an OpenAPI document
version: 1.0.0
paths:
/items:
get:
responses:
'200':
description: sample description
content:
application/json:
examples:
item-list:
$ref: './examples.yaml#/components/examples/item-list'
Loading