Skip to content
Open
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
10 changes: 7 additions & 3 deletions src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ public virtual void SerializeAsV2(IOpenApiWriter writer)
return Id;
}

if (string.Equals(ExternalResource, Id, StringComparison.Ordinal) && !Id.Contains("#"))
{
return ExternalResource;
}

return ExternalResource + "#/components/" + Type.GetDisplayName() + "/"+ Id;
}

Expand Down Expand Up @@ -329,9 +334,8 @@ internal void SetJsonPointerPath(string pointer, string nodeLocation)
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))
// Absolute reference, anchor, or relative pointer (e.g. "#/components/schemas/...", full URL, or "schemas/uuid.schema.json")
else if (!string.Equals(ReferenceV3, pointer, StringComparison.OrdinalIgnoreCase))
{
ReferenceV3 = pointer;
}
Expand Down
44 changes: 30 additions & 14 deletions src/Microsoft.OpenApi/Models/OpenApiDocument.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System;
Expand Down Expand Up @@ -630,14 +630,21 @@ private static string ConvertByteArrayToString(byte[] hash)

string uriLocation;
var id = reference.Id;
if (!string.IsNullOrEmpty(id) && id!.Contains('/')) // this means its a URL reference
var referenceV3 = !string.IsNullOrEmpty(reference.ReferenceV3) ? reference.ReferenceV3! : string.Empty;
var isStandaloneExternal = reference.IsExternal &&
(!string.IsNullOrEmpty(referenceV3) ? !referenceV3.Contains("#") : (id is not null && !id.Contains("#")));

if (isStandaloneExternal)
{
uriLocation = id ?? reference.ExternalResource ?? string.Empty;
}
else if (!string.IsNullOrEmpty(id) && id!.Contains("/"))
{
uriLocation = id!;
}
else
{
string relativePath;
var referenceV3 = !string.IsNullOrEmpty(reference.ReferenceV3) ? reference.ReferenceV3! : string.Empty;

if (reference.Type is ReferenceType.Schema && useExternal && TryGetPlainNameFragment(referenceV3, out var plainNameFragment))
{
Expand Down Expand Up @@ -668,19 +675,28 @@ private static string ConvertByteArrayToString(byte[] hash)
: BaseUri + relativePath;
}

var absoluteUri =
#if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER
uriLocation.StartsWith('#')
#else
uriLocation.StartsWith("#", StringComparison.OrdinalIgnoreCase)
#endif
switch
Uri? resolvedUri = null;
if (Uri.TryCreate(uriLocation, UriKind.Absolute, out var absUri))
{
true => new Uri(BaseUri, uriLocation).AbsoluteUri,
false => new Uri(uriLocation).AbsoluteUri
};
resolvedUri = absUri;
}
else if (useExternal && reference.ExternalResource is not null && Workspace?.GetDocumentId(reference.ExternalResource) is Uri extDocUri)
{
resolvedUri = extDocUri;
}
else if (BaseUri is not null && Uri.TryCreate(BaseUri, uriLocation, out var relCombinedUri))
{
resolvedUri = relCombinedUri;
}

if (resolvedUri is null)
{
return null;
}

var absoluteUri = resolvedUri.AbsoluteUri;

if (reference.Type is ReferenceType.Schema && absoluteUri.Contains('#') && parentSchema is not null)
if (reference.Type is ReferenceType.Schema && absoluteUri.Contains("#") && parentSchema is not null)
{
return Workspace?.ResolveJsonSchemaReference(absoluteUri, parentSchema);
}
Expand Down
27 changes: 17 additions & 10 deletions src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -45,18 +45,25 @@ internal async Task<OpenApiDiagnostic> LoadAsync(BaseOpenApiReference reference,
// If not already in workspace, load it and process references
if (item.ExternalResource is not null && !_workspace.Contains(item.ExternalResource))
{
var uri = new Uri(item.ExternalResource, UriKind.RelativeOrAbsolute);
var input = await _loader.LoadAsync(item.HostDocument!.BaseUri, uri, cancellationToken).ConfigureAwait(false);
var result = await OpenApiDocument.LoadAsync(input, format, _readerSettings, cancellationToken).ConfigureAwait(false);
// Merge diagnostics
if (result.Diagnostic != null)
try
{
diagnostic.AppendDiagnostic(result.Diagnostic, item.ExternalResource);
var uri = new Uri(item.ExternalResource, UriKind.RelativeOrAbsolute);
var input = await _loader.LoadAsync(item.HostDocument!.BaseUri, uri, cancellationToken).ConfigureAwait(false);
var result = await OpenApiDocument.LoadAsync(input, format, _readerSettings, cancellationToken).ConfigureAwait(false);
// Merge diagnostics
if (result.Diagnostic != null)
{
diagnostic.AppendDiagnostic(result.Diagnostic, item.ExternalResource);
}
if (result.Document != null)
{
var loadDiagnostic = await LoadAsync(item, result.Document, format, diagnostic, cancellationToken).ConfigureAwait(false);
diagnostic = loadDiagnostic;
}
}
if (result.Document != null)
catch (Exception ex)
{
var loadDiagnostic = await LoadAsync(item, result.Document, format, diagnostic, cancellationToken).ConfigureAwait(false);
diagnostic = loadDiagnostic;
diagnostic.Errors.Add(new OpenApiError(string.Empty, ex.Message));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,28 @@ public static OpenApiDiscriminator LoadDiscriminator(JsonNode node, OpenApiDocum
public static OpenApiSchemaReference LoadMapping(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
{
var pointer = node.GetScalarValue() ?? throw new InvalidOperationException("Could not get a pointer reference");
if (IsBareSchemaName(pointer))
{
var schemaRef = new OpenApiSchemaReference(pointer, hostDocument);
schemaRef.Reference.SetJsonPointerPath(pointer, context.GetLocation());
return schemaRef;
}

var reference = GetReferenceIdAndExternalResource(pointer);
return new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
result.Reference.SetJsonPointerPath(pointer, context.GetLocation());
return result;
}

private static bool IsBareSchemaName(string pointer)
{
return !pointer.StartsWith("#", StringComparison.OrdinalIgnoreCase)
&& !pointer.Contains("/")
&& !pointer.Contains("#")
&& !pointer.Contains(":")
&& !pointer.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !pointer.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)
&& !pointer.EndsWith(".yml", StringComparison.OrdinalIgnoreCase);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe

var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase);
string? externalResource = null;
if (isExternalResource && pointer.Contains('#'))
if (isExternalResource)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in commit d0eeafa. In BaseOpenApiReference.SetJsonPointerPath, authored pointers are now preserved directly without requiring # or http, correctly maintaining fragmentless relative references such as schemas/uuid.schema.json. In GetExternalReferenceV3(), standalone external references where ExternalResource == Id && !Id.Contains(#) return ExternalResource directly. Added round-trip serialization assertions in RelativeReferenceTests.cs and OpenApiSchemaReferenceDeserializerTests.cs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in commit d0eeafa. In both OpenApiDiscriminatorDeserializer (V31 and V32), LoadMapping now detects bare schema names (via IsBareSchemaName, checking that the value is not a URI reference or file path) and constructs a local OpenApiSchemaReference(pointer, hostDocument) with ExternalResource = null. This resolves the local schema in hostDocument.Components.Schemas properly for both mapping and defaultMapping (and x-oas-default-mapping). Added regression tests in V31Tests/OpenApiDiscriminatorTests.cs and V32Tests/OpenApiDiscriminatorTests.cs.

{
externalResource = pointer.Split('#')[0].TrimEnd('#');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,28 @@ public static OpenApiDiscriminator LoadDiscriminator(JsonNode node, OpenApiDocum
public static OpenApiSchemaReference LoadMapping(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
{
var pointer = node.GetScalarValue() ?? throw new InvalidOperationException("Could not get a pointer reference");
if (IsBareSchemaName(pointer))
{
var schemaRef = new OpenApiSchemaReference(pointer, hostDocument);
schemaRef.Reference.SetJsonPointerPath(pointer, context.GetLocation());
return schemaRef;
}

var reference = GetReferenceIdAndExternalResource(pointer);
return new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
result.Reference.SetJsonPointerPath(pointer, context.GetLocation());
return result;
}

private static bool IsBareSchemaName(string pointer)
{
return !pointer.StartsWith("#", StringComparison.OrdinalIgnoreCase)
&& !pointer.Contains("/")
&& !pointer.Contains("#")
&& !pointer.Contains(":")
&& !pointer.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !pointer.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)
&& !pointer.EndsWith(".yml", StringComparison.OrdinalIgnoreCase);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe

var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase);
string? externalResource = null;
if (isExternalResource && pointer.Contains('#'))
if (isExternalResource)
{
externalResource = pointer.Split('#')[0].TrimEnd('#');
}
Expand Down
12 changes: 9 additions & 3 deletions src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System;
Expand Down Expand Up @@ -949,10 +949,16 @@ public bool Contains(string location)

private Uri? ToLocationUrl(string location)
{
if (BaseUrl is not null)
if (BaseUrl is not null && Uri.TryCreate(BaseUrl, location, out var relativeUri))
{
return new(BaseUrl, location);
return relativeUri;
}

if (Uri.TryCreate(location, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri;
}

return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using Microsoft.OpenApi.Reader;
Expand All @@ -14,7 +14,7 @@ public class OpenApiComponentsTests
[InlineData("A:\\Dir\\File.json#/components/schemas/ExternalAbsWindowsPathModel", "ExternalAbsWindowsPathModel", "A:\\Dir\\File.json")]
[InlineData("/Dir/File.json#/components/schemas/ExternalAbsUnixPathModel", "ExternalAbsUnixPathModel", "/Dir/File.json")]
[InlineData("https://host.lan:1234/path/to/file/resource.json#/components/schemas/ExternalHttpsModel", "ExternalHttpsModel", "https://host.lan:1234/path/to/file/resource.json")]
[InlineData("File.json", "File.json", null)]
[InlineData("File.json", "File.json", "File.json")]
public void ParseExternalSchemaReferenceShouldSucceed(string reference, string referenceId, string externalResource)
{
var input = $@"{{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System.Collections.Generic;
Expand Down Expand Up @@ -42,5 +42,59 @@ public async Task ParseBasicDiscriminatorShouldSucceed()
DefaultMapping = new OpenApiSchemaReference("Animal", openApiDocument)
}, discriminator);
}

[Fact]
public void ParseDiscriminatorWithBareSchemaNameMappingResolvesLocalTarget()
{
var openApi = """
{
"openapi": "3.1.1",
"info": { "title": "Repro", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Dog": {
"type": "object",
"properties": { "bark": { "type": "boolean" } }
},
"Pet": {
"type": "object",
"required": [ "petType" ],
"properties": {
"petType": { "type": "string" }
},
"discriminator": {
"propertyName": "petType",
"mapping": {
"dog": "Dog"
},
"x-oas-default-mapping": "Dog"
}
}
}
}
}
""";

var readResult = OpenApiDocument.Parse(openApi, "json");
var doc = readResult.Document;
Assert.NotNull(doc);
Assert.Empty(readResult.Diagnostic.Errors);

var petSchema = Assert.IsType<OpenApiSchema>(doc.Components.Schemas["Pet"]);
Assert.NotNull(petSchema.Discriminator);

var dogMapping = petSchema.Discriminator.Mapping["dog"];
Assert.False(dogMapping.Reference.IsExternal);
Assert.Null(dogMapping.Reference.ExternalResource);
Assert.Equal("Dog", dogMapping.Reference.Id);

var dogSchema = Assert.IsType<OpenApiSchema>(doc.Components.Schemas["Dog"]);
Assert.Same(dogSchema, dogMapping.Target);

Assert.NotNull(petSchema.Discriminator.DefaultMapping);
Assert.False(petSchema.Discriminator.DefaultMapping.Reference.IsExternal);
Assert.Same(dogSchema, petSchema.Discriminator.DefaultMapping.Target);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,31 @@ public void ShouldDeserializeSchemaReferenceAnnotations()
Assert.Equal("example reference value", resultReference.Examples[0]?.ToString());
Assert.NotNull(resultReference.Target);
}

[Theory]
[InlineData("schemas/uuid.schema.json", "schemas/uuid.schema.json", "schemas/uuid.schema.json")]
[InlineData("./schemas/uuid.schema.json", "./schemas/uuid.schema.json", "./schemas/uuid.schema.json")]
[InlineData("uuid.schema.json", "uuid.schema.json", "uuid.schema.json")]
[InlineData("https://example.com/schemas/resource.json", "https://example.com/schemas/resource.json", "https://example.com/schemas/resource.json")]
public void ShouldDeserializeExternalSchemaReferenceWithoutFragment(string refPointer, string expectedId, string expectedExternalResource)
{
var json = $$"""
{
"$ref": "{{refPointer}}"
}
""";

var hostDocument = new OpenApiDocument();
var jsonNode = JsonNode.Parse(json);

var result = OpenApiV31Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new()));

Assert.NotNull(result);
var resultReference = Assert.IsType<OpenApiSchemaReference>(result);

Assert.True(resultReference.Reference.IsExternal);
Assert.Equal(expectedId, resultReference.Reference.Id);
Assert.Equal(expectedExternalResource, resultReference.Reference.ExternalResource);
Assert.Equal(refPointer, resultReference.Reference.ReferenceV3);
}
}
Loading