From 329e9b3177b9b877bd5b2a4fbb98582a3006fd79 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 16 Apr 2026 13:46:38 -0400 Subject: [PATCH 1/6] feat: adds path translation from v3 to v2 for validation errors Signed-off-by: Vincent Biret --- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 3 + .../IOpenApiPathRepresentationPolicy.cs | 24 ++ .../Services/OpenApiPathHelper.cs | 386 ++++++++++++++++++ .../Validations/OpenApiValidatorError.cs | 18 + .../Services/OpenApiPathHelperTests.cs | 318 +++++++++++++++ 5 files changed, 749 insertions(+) create mode 100644 src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs create mode 100644 src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs create mode 100644 test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..b55d7de69 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.OpenApi.OpenApiPathHelper +static Microsoft.OpenApi.OpenApiPathHelper.GetVersionedPath(string! path, Microsoft.OpenApi.OpenApiSpecVersion targetVersion) -> string? +Microsoft.OpenApi.OpenApiValidatorError.GetVersionedPointer(Microsoft.OpenApi.OpenApiSpecVersion targetVersion) -> string? diff --git a/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs b/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs new file mode 100644 index 000000000..992e82afe --- /dev/null +++ b/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.OpenApi; +/// +/// Defines a policy for matching and transforming OpenAPI JSON Pointer paths +/// between specification versions. +/// +internal interface IOpenApiPathRepresentationPolicy +{ + /// + /// Determines whether this policy can handle the given path. + /// + /// The JSON Pointer path to evaluate. + /// true if this policy applies to the given path; otherwise, false. + bool IsMatch(string path); + + /// + /// Transforms the given path to its equivalent in the target specification version. + /// + /// The JSON Pointer path to transform. + /// The transformed path, or null if the path has no equivalent in the target version. + string? GetVersionedPath(string path); +} diff --git a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs new file mode 100644 index 000000000..17effb7da --- /dev/null +++ b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; + +namespace Microsoft.OpenApi; + +/// +/// Provides helper methods for converting OpenAPI JSON Pointer paths between specification versions. +/// +public static class OpenApiPathHelper +{ + private static readonly Dictionary _policies = new() + { + [OpenApiSpecVersion.OpenApi2_0] = + [ + // Order matters: null policies first, then transformations. + new V2UnsupportedPathPolicy(), + new V2ComponentRenamePolicy(), + new V2ResponseContentUnwrappingPolicy(), + new V2HeaderSchemaUnwrappingPolicy(), + ], + [OpenApiSpecVersion.OpenApi3_0] = + [ + new V3_0UnsupportedPathPolicy(), + ], + }; + + /// + /// Converts a JSON Pointer path produced by the walker (latest version) to its equivalent + /// for the specified target specification version. + /// + /// The latest version JSON Pointer path (e.g. #/paths/~1items/get/responses/200/content/application~1json/schema). + /// The target OpenAPI specification version. + /// + /// The equivalent path in the target version, the original path if no transformation is needed, + /// or null if the path has no equivalent in the target version. + /// + public static string? GetVersionedPath(string path, OpenApiSpecVersion targetVersion) + { + if (string.IsNullOrEmpty(path) || targetVersion == OpenApiSpecVersion.OpenApi3_2) + { + return path; + } + + if (!_policies.TryGetValue(targetVersion, out var matchingPolicies)) + { + return path; + } + + foreach (var policy in matchingPolicies) + { + if (policy.IsMatch(path)) + { + return policy.GetVersionedPath(path); + } + } + + return path; + } + + /// + /// Splits a JSON Pointer path into its constituent segments, stripping the #/ prefix. + /// + internal static string[] GetSegments(string path) + { + if (string.IsNullOrEmpty(path)) + { + return []; + } + + ReadOnlySpan span = path.AsSpan(); + if (span.StartsWith("#/".AsSpan(), StringComparison.Ordinal)) + { + span = span.Slice(2); + } + + if (span.IsEmpty) + { + return []; + } + + return span.ToString().Split('/'); + } + + /// + /// Rebuilds a JSON Pointer path from segments. + /// + internal static string BuildPath(string[] segments) + { + return "#/" + string.Join("/", segments); + } + + /// + /// Removes segments at the specified indices by building a new array without them. + /// + internal static string[] RemoveSegments(string[] segments, int startIndex, int count) + { + var result = new string[segments.Length - count]; + Array.Copy(segments, 0, result, 0, startIndex); + Array.Copy(segments, startIndex + count, result, startIndex, segments.Length - startIndex - count); + return result; + } +} + +/// +/// Returns null for paths that have no equivalent in OpenAPI v2 (Swagger). +/// Covers: servers, webhooks, callbacks, links, requestBody (inline), +/// encoding, and unsupported component types. +/// +internal sealed class V2UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy +{ + private static readonly HashSet UnsupportedComponentTypes = new(StringComparer.Ordinal) + { + OpenApiConstants.Examples, + OpenApiConstants.Headers, + OpenApiConstants.PathItems, + OpenApiConstants.Links, + OpenApiConstants.Callbacks, + OpenApiConstants.RequestBodies, + OpenApiConstants.MediaTypes, + }; + + public bool IsMatch(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + if (segments.Length == 0) + { + return false; + } + + // Top-level servers: #/servers/** + if (string.Equals(segments[0], OpenApiConstants.Servers, StringComparison.Ordinal)) + { + return true; + } + + // Top-level webhooks: #/webhooks/** + if (string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal)) + { + return true; + } + + // Unsupported component types: #/components/{unsupported}/** + if (segments.Length >= 2 && + string.Equals(segments[0], OpenApiConstants.Components, StringComparison.Ordinal) && + UnsupportedComponentTypes.Contains(segments[1])) + { + return true; + } + + // Walk through segments looking for v3-only constructs in context + for (var i = 1; i < segments.Length; i++) + { + var segment = segments[i]; + + // servers at any nested level (path-item/operation level) + if (string.Equals(segment, OpenApiConstants.Servers, StringComparison.Ordinal)) + { + return true; + } + + // callbacks at operation level + if (string.Equals(segment, OpenApiConstants.Callbacks, StringComparison.Ordinal)) + { + return true; + } + + // links at response level + if (string.Equals(segment, OpenApiConstants.Links, StringComparison.Ordinal)) + { + return true; + } + + // inline requestBody at operation level + if (string.Equals(segment, OpenApiConstants.RequestBody, StringComparison.Ordinal)) + { + return true; + } + + // encoding under content/{mediaType}: .../content/{mt}/encoding/** + if (string.Equals(segment, OpenApiConstants.Encoding, StringComparison.Ordinal) && + i >= 2 && + string.Equals(segments[i - 2], OpenApiConstants.Content, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + public string? GetVersionedPath(string path) => null; +} + +/// +/// Returns null for paths that have no equivalent in OpenAPI v3.0. +/// Covers: webhooks (added in v3.1). +/// +internal sealed class V3_0UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy +{ + public bool IsMatch(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + if (segments.Length == 0) + { + return false; + } + + // webhooks were added in v3.1 + return string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal); + } + + public string? GetVersionedPath(string path) => null; +} + +/// +/// Renames v3 component paths to their v2 equivalents. +/// +/// #/components/schemas/{name}/**#/definitions/{name}/** +/// #/components/parameters/{name}/**#/parameters/{name}/** +/// #/components/responses/{name}/**#/responses/{name}/** +/// #/components/securitySchemes/{name}/**#/securityDefinitions/{name}/** +/// +/// +internal sealed class V2ComponentRenamePolicy : IOpenApiPathRepresentationPolicy +{ + private static readonly Dictionary ComponentMappings = new(StringComparer.Ordinal) + { + [OpenApiConstants.Schemas] = OpenApiConstants.Definitions, + [OpenApiConstants.Parameters] = OpenApiConstants.Parameters, + [OpenApiConstants.Responses] = OpenApiConstants.Responses, + [OpenApiConstants.SecuritySchemes] = OpenApiConstants.SecurityDefinitions, + }; + + public bool IsMatch(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + + return segments.Length >= 2 && + string.Equals(segments[0], OpenApiConstants.Components, StringComparison.Ordinal) && + ComponentMappings.ContainsKey(segments[1]); + } + + public string? GetVersionedPath(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + var v2Name = ComponentMappings[segments[1]]; + + // Remove "components" (index 0) and replace the component type name (index 1) + segments[1] = v2Name; + var result = OpenApiPathHelper.RemoveSegments(segments, 0, 1); + + // Apply further transformations to the result (e.g., schema unwrapping within components) + var resultPath = OpenApiPathHelper.BuildPath(result); + return ApplyNestedTransformations(resultPath); + } + + private static string? ApplyNestedTransformations(string path) + { + // After renaming, a component response might still have content/{mt}/schema + // e.g. #/components/responses/NotFound/content/application~1json/schema → + // #/responses/NotFound/schema (needs content unwrapping) + var segments = OpenApiPathHelper.GetSegments(path); + segments = V2ResponseContentUnwrappingPolicy.UnwrapContentSegments(segments); + segments = V2HeaderSchemaUnwrappingPolicy.UnwrapHeaderSchemaSegments(segments); + return OpenApiPathHelper.BuildPath(segments); + } +} + +/// +/// Unwraps response content media type from v3 paths to v2 paths. +/// .../responses/{code}/content/{mediaType}/schema/**.../responses/{code}/schema/** +/// +internal sealed class V2ResponseContentUnwrappingPolicy : IOpenApiPathRepresentationPolicy +{ + public bool IsMatch(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + return FindContentIndex(segments) >= 0; + } + + public string? GetVersionedPath(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + segments = UnwrapContentSegments(segments); + return OpenApiPathHelper.BuildPath(segments); + } + + /// + /// Finds the "content" segment that follows a "responses/{code}" sequence. + /// Returns the index of "content", or -1 if not found. + /// + private static int FindContentIndex(string[] segments) + { + // Look for: responses / {code} / content / {mediaType} + for (var i = 0; i < segments.Length - 3; i++) + { + if (string.Equals(segments[i], OpenApiConstants.Responses, StringComparison.Ordinal) && + string.Equals(segments[i + 2], OpenApiConstants.Content, StringComparison.Ordinal)) + { + return i + 2; + } + } + + return -1; + } + + /// + /// Removes the "content" and "{mediaType}" segments from a response path. + /// + internal static string[] UnwrapContentSegments(string[] segments) + { + // Look for: responses / {code} / content / {mediaType} / ... + for (var i = 0; i < segments.Length - 3; i++) + { + if (string.Equals(segments[i], OpenApiConstants.Responses, StringComparison.Ordinal) && + string.Equals(segments[i + 2], OpenApiConstants.Content, StringComparison.Ordinal)) + { + // Remove "content" (i+2) and "{mediaType}" (i+3) + return OpenApiPathHelper.RemoveSegments(segments, i + 2, 2); + } + } + + return segments; + } +} + +/// +/// Unwraps the "schema" segment from header paths in v3 to produce v2-style header paths. +/// .../headers/{name}/schema/**.../headers/{name}/** +/// +internal sealed class V2HeaderSchemaUnwrappingPolicy : IOpenApiPathRepresentationPolicy +{ + public bool IsMatch(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + return FindHeaderSchemaIndex(segments) >= 0; + } + + public string? GetVersionedPath(string path) + { + var segments = OpenApiPathHelper.GetSegments(path); + segments = UnwrapHeaderSchemaSegments(segments); + return OpenApiPathHelper.BuildPath(segments); + } + + /// + /// Finds the "schema" segment that follows a "headers/{name}" sequence. + /// Returns the index of "schema", or -1 if not found. + /// + private static int FindHeaderSchemaIndex(string[] segments) + { + // Look for: headers / {name} / schema + for (var i = 0; i < segments.Length - 2; i++) + { + if (string.Equals(segments[i], OpenApiConstants.Headers, StringComparison.Ordinal) && + string.Equals(segments[i + 2], OpenApiConstants.Schema, StringComparison.Ordinal)) + { + return i + 2; + } + } + + return -1; + } + + /// + /// Removes the "schema" segment from a header path. + /// + internal static string[] UnwrapHeaderSchemaSegments(string[] segments) + { + // Look for: headers / {name} / schema + for (var i = 0; i < segments.Length - 2; i++) + { + if (string.Equals(segments[i], OpenApiConstants.Headers, StringComparison.Ordinal) && + string.Equals(segments[i + 2], OpenApiConstants.Schema, StringComparison.Ordinal)) + { + // Remove "schema" (i+2) + return OpenApiPathHelper.RemoveSegments(segments, i + 2, 1); + } + } + + return segments; + } +} diff --git a/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs b/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs index b42e05d0c..d69018704 100644 --- a/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs +++ b/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs @@ -20,5 +20,23 @@ public OpenApiValidatorError(string ruleName, string pointer, string message) : /// Name of rule that detected the error. /// public string RuleName { get; set; } + + /// + /// Gets the error pointer translated to the equivalent path for the specified OpenAPI version. + /// + /// The target OpenAPI specification version. + /// + /// The equivalent pointer in the target version, the original pointer if no transformation is needed, + /// or null if the pointer has no equivalent in the target version. + /// + public string? GetVersionedPointer(OpenApiSpecVersion targetVersion) + { + if (Pointer is null) + { + return null; + } + + return OpenApiPathHelper.GetVersionedPath(Pointer, targetVersion); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs b/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs new file mode 100644 index 000000000..f514660d1 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +#nullable enable +using Xunit; + +namespace Microsoft.OpenApi.Tests.Services; + +public class OpenApiPathHelperTests +{ + #region Identity (no transformation needed) + + [Theory] + [InlineData("#/info")] + [InlineData("#/info/title")] + [InlineData("#/paths")] + [InlineData("#/paths/~1items")] + [InlineData("#/paths/~1items/get")] + [InlineData("#/paths/~1items/get/responses")] + [InlineData("#/paths/~1items/get/responses/200")] + [InlineData("#/paths/~1items/get/responses/200/description")] + [InlineData("#/tags")] + [InlineData("#/externalDocs")] + [InlineData("#/security")] + public void V2_IdenticalPaths_ReturnedAsIs(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(path, result); + } + + #endregion + + #region Null / empty / v3.2 passthrough + + [Theory] + [InlineData(null)] + [InlineData("")] + public void NullOrEmptyPath_ReturnsAsIs(string? path) + { + var result = OpenApiPathHelper.GetVersionedPath(path!, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(path, result); + } + + [Theory] + [InlineData("#/paths/~1items/get/responses/200/content/application~1json/schema")] + [InlineData("#/components/schemas/Pet")] + [InlineData("#/servers/0")] + [InlineData("#/webhooks/newPet/post")] + public void V32_AllPaths_ReturnedAsIs(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi3_2); + Assert.Equal(path, result); + } + + [Theory] + [InlineData("#/paths/~1items/get/responses/200/content/application~1json/schema")] + [InlineData("#/components/schemas/Pet")] + [InlineData("#/servers/0")] + public void V31_AllPaths_ReturnedAsIs(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi3_1); + Assert.Equal(path, result); + } + + #endregion + + #region V3.0 unsupported paths + + [Theory] + [InlineData("#/webhooks/newPet")] + [InlineData("#/webhooks/newPet/post")] + [InlineData("#/webhooks/newPet/post/responses/200")] + public void V30_Webhooks_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi3_0); + Assert.Null(result); + } + + [Fact] + public void V30_NonWebhookPaths_ReturnedAsIs() + { + var path = "#/paths/~1items/get/responses/200/content/application~1json/schema"; + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi3_0); + Assert.Equal(path, result); + } + + #endregion + + #region V2 unsupported paths (null returns) + + [Theory] + [InlineData("#/servers")] + [InlineData("#/servers/0")] + [InlineData("#/servers/0/url")] + public void V2_TopLevelServers_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/paths/~1items/servers/0")] + [InlineData("#/paths/~1items/get/servers/0")] + public void V2_NestedServers_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/webhooks/newPet")] + [InlineData("#/webhooks/newPet/post")] + [InlineData("#/webhooks/newPet/post/responses/200")] + public void V2_Webhooks_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/paths/~1items/get/callbacks/onEvent")] + [InlineData("#/paths/~1items/get/callbacks/onEvent/~1callback/post")] + public void V2_Callbacks_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/paths/~1items/get/responses/200/links/GetItemById")] + [InlineData("#/paths/~1items/get/responses/200/links/GetItemById/operationId")] + public void V2_Links_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/paths/~1items/post/requestBody")] + [InlineData("#/paths/~1items/post/requestBody/content/application~1json/schema")] + public void V2_InlineRequestBody_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/components/examples/FooExample")] + [InlineData("#/components/headers/X-Rate-Limit")] + [InlineData("#/components/pathItems/SharedItem")] + [InlineData("#/components/links/GetItemById")] + [InlineData("#/components/callbacks/onEvent")] + [InlineData("#/components/requestBodies/PetBody")] + [InlineData("#/components/mediaTypes/JsonMedia")] + public void V2_UnsupportedComponentTypes_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + [Theory] + [InlineData("#/paths/~1items/post/responses/200/content/application~1json/encoding/color")] + public void V2_Encoding_ReturnsNull(string path) + { + var result = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); + Assert.Null(result); + } + + #endregion + + #region V2 component renames + + [Theory] + [InlineData("#/components/schemas/Pet", "#/definitions/Pet")] + [InlineData("#/components/schemas/Pet/properties/name", "#/definitions/Pet/properties/name")] + [InlineData("#/components/schemas/Pet~0Special", "#/definitions/Pet~0Special")] + public void V2_ComponentsSchemas_RenamedToDefinitions(string input, string expected) + { + var result = OpenApiPathHelper.GetVersionedPath(input, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("#/components/parameters/SkipParam", "#/parameters/SkipParam")] + [InlineData("#/components/parameters/SkipParam/schema/type", "#/parameters/SkipParam/schema/type")] + public void V2_ComponentsParameters_RenamedToParameters(string input, string expected) + { + var result = OpenApiPathHelper.GetVersionedPath(input, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("#/components/responses/NotFound", "#/responses/NotFound")] + [InlineData("#/components/responses/NotFound/description", "#/responses/NotFound/description")] + public void V2_ComponentsResponses_RenamedToResponses(string input, string expected) + { + var result = OpenApiPathHelper.GetVersionedPath(input, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("#/components/securitySchemes/ApiKeyAuth", "#/securityDefinitions/ApiKeyAuth")] + [InlineData("#/components/securitySchemes/OAuth2/flows", "#/securityDefinitions/OAuth2/flows")] + public void V2_ComponentsSecuritySchemes_RenamedToSecurityDefinitions(string input, string expected) + { + var result = OpenApiPathHelper.GetVersionedPath(input, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(expected, result); + } + + #endregion + + #region V2 response content schema unwrapping + + [Theory] + [InlineData( + "#/paths/~1items/get/responses/200/content/application~1json/schema", + "#/paths/~1items/get/responses/200/schema")] + [InlineData( + "#/paths/~1items/get/responses/200/content/application~1octet-stream/schema", + "#/paths/~1items/get/responses/200/schema")] + [InlineData( + "#/paths/~1items/get/responses/200/content/application~1json/schema/properties/name", + "#/paths/~1items/get/responses/200/schema/properties/name")] + [InlineData( + "#/paths/~1items/get/responses/default/content/application~1json/schema", + "#/paths/~1items/get/responses/default/schema")] + public void V2_ResponseContentSchema_Unwrapped(string input, string expected) + { + var result = OpenApiPathHelper.GetVersionedPath(input, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(expected, result); + } + + [Fact] + public void V2_ComponentResponseWithContent_UnwrapsContentAndRenames() + { + // #/components/responses/NotFound/content/application~1json/schema + // → first: #/responses/NotFound/content/application~1json/schema (component rename) + // → then: #/responses/NotFound/schema (content unwrapping) + var result = OpenApiPathHelper.GetVersionedPath( + "#/components/responses/NotFound/content/application~1json/schema", + OpenApiSpecVersion.OpenApi2_0); + Assert.Equal("#/responses/NotFound/schema", result); + } + + #endregion + + #region V2 header schema unwrapping + + [Theory] + [InlineData( + "#/paths/~1items/get/responses/200/headers/X-Rate-Limit/schema/type", + "#/paths/~1items/get/responses/200/headers/X-Rate-Limit/type")] + [InlineData( + "#/paths/~1items/get/responses/200/headers/X-Rate-Limit/schema", + "#/paths/~1items/get/responses/200/headers/X-Rate-Limit")] + public void V2_HeaderSchema_Unwrapped(string input, string expected) + { + var result = OpenApiPathHelper.GetVersionedPath(input, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal(expected, result); + } + + #endregion + + #region Issue 2806 reproduction + + [Fact] + public void Issue2806_ResponseContentSchemaPath_ConvertedToV2() + { + // The exact scenario from the issue: + // v3 walker produces: #/paths/~1items/get/responses/200/content/application~1octet-stream/schema + // v2 document expects: #/paths/~1items/get/responses/200/schema + var v3Path = "#/paths/~1items/get/responses/200/content/application~1octet-stream/schema"; + var v2Path = OpenApiPathHelper.GetVersionedPath(v3Path, OpenApiSpecVersion.OpenApi2_0); + Assert.Equal("#/paths/~1items/get/responses/200/schema", v2Path); + } + + #endregion + + #region OpenApiValidatorError.GetVersionedPointer + + [Fact] + public void ValidatorError_GetVersionedPointer_DelegatesToHelper() + { + var error = new OpenApiValidatorError( + "TestRule", + "#/paths/~1items/get/responses/200/content/application~1json/schema", + "Test error message"); + + var v2Pointer = error.GetVersionedPointer(OpenApiSpecVersion.OpenApi2_0); + Assert.Equal("#/paths/~1items/get/responses/200/schema", v2Pointer); + } + + [Fact] + public void ValidatorError_GetVersionedPointer_NullForUnsupported() + { + var error = new OpenApiValidatorError( + "TestRule", + "#/servers/0", + "Test error message"); + + var v2Pointer = error.GetVersionedPointer(OpenApiSpecVersion.OpenApi2_0); + Assert.Null(v2Pointer); + } + + [Fact] + public void ValidatorError_GetVersionedPointer_V32_ReturnsOriginal() + { + var error = new OpenApiValidatorError( + "TestRule", + "#/components/schemas/Pet", + "Test error message"); + + var v32Pointer = error.GetVersionedPointer(OpenApiSpecVersion.OpenApi3_2); + Assert.Equal("#/components/schemas/Pet", v32Pointer); + } + + #endregion +} From 098f24e878e98191e13cee934e3791beb902f535 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 16 Apr 2026 13:53:26 -0400 Subject: [PATCH 2/6] docs: adds documentation about the experimental path mapping helpers Signed-off-by: Vincent Biret --- docs/experimental-apis.md | 136 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/experimental-apis.md diff --git a/docs/experimental-apis.md b/docs/experimental-apis.md new file mode 100644 index 000000000..176960713 --- /dev/null +++ b/docs/experimental-apis.md @@ -0,0 +1,136 @@ +# Experimental APIs + +The Microsoft.OpenApi library includes a set of experimental APIs that are available for evaluation. +These APIs are subject to change or removal in future versions without following the usual deprecation process. + +Using an experimental API will produce a compiler diagnostic that must be explicitly suppressed +to acknowledge the experimental nature of the API. + +## Suppressing Experimental API Diagnostics + +To use an experimental API, suppress the corresponding diagnostic in your project: + +### Per call site + +```csharp +#pragma warning disable OAI020 +var v2Path = OpenApiPathHelper.GetVersionedPath(path, OpenApiSpecVersion.OpenApi2_0); +#pragma warning restore OAI020 +``` + +### Per project (in `.csproj`) + +```xml + + $(NoWarn);OAI020 + +``` + +--- + +## OAI020 — Path Version Conversion + +| Diagnostic ID | Applies to | Since | +|---|---|---| +| `OAI020` | `OpenApiPathHelper`, `OpenApiValidatorError.GetVersionedPointer` | v3.6.0 | + +### Overview + +The path version conversion APIs translate JSON Pointer paths produced by the `OpenApiWalker` +(which always uses the v3 document model) into their equivalents for a specified OpenAPI +specification version. + +This is useful when validation errors or walker paths need to be reported relative to +the original document version (e.g., Swagger v2) rather than the internal v3 representation. + +### APIs + +#### `OpenApiPathHelper.GetVersionedPath(string path, OpenApiSpecVersion targetVersion)` + +Converts a v3-style JSON Pointer path to its equivalent for the target specification version. + +**Parameters:** + +- `path` — The v3-style JSON Pointer (e.g., `#/paths/~1items/get/responses/200/content/application~1json/schema`). +- `targetVersion` — The target OpenAPI specification version. + +**Returns:** The equivalent path in the target version, the original path unchanged if no +transformation is needed, or `null` if the construct has no equivalent in the target version. + +**Example:** + +```csharp +#pragma warning disable OAI020 +// v3 path from the walker +var v3Path = "#/paths/~1items/get/responses/200/content/application~1octet-stream/schema"; + +// Convert to v2 equivalent +var v2Path = OpenApiPathHelper.GetVersionedPath(v3Path, OpenApiSpecVersion.OpenApi2_0); +// Result: "#/paths/~1items/get/responses/200/schema" + +// Convert to v3.2 (no transformation needed) +var v32Path = OpenApiPathHelper.GetVersionedPath(v3Path, OpenApiSpecVersion.OpenApi3_2); +// Result: "#/paths/~1items/get/responses/200/content/application~1octet-stream/schema" + +// v3-only construct with no v2 equivalent +var serversPath = "#/servers/0"; +var v2Result = OpenApiPathHelper.GetVersionedPath(serversPath, OpenApiSpecVersion.OpenApi2_0); +// Result: null +#pragma warning restore OAI020 +``` + +#### `OpenApiValidatorError.GetVersionedPointer(OpenApiSpecVersion targetVersion)` + +A convenience method on validation errors that translates the error's `Pointer` property to +the equivalent path for the target specification version. + +**Example:** + +```csharp +var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); +var walker = new OpenApiWalker(validator); +walker.Walk(document); + +foreach (var error in validator.Errors) +{ + if (error is OpenApiValidatorError validatorError) + { +#pragma warning disable OAI020 + var v2Pointer = validatorError.GetVersionedPointer(OpenApiSpecVersion.OpenApi2_0); +#pragma warning restore OAI020 + if (v2Pointer is not null) + { + Console.WriteLine($"Error at {v2Pointer}: {validatorError.Message}"); + } + } +} +``` + +### Supported Transformations (v2 target) + +| v3 Path Pattern | v2 Equivalent | +|---|---| +| `#/components/schemas/{name}/**` | `#/definitions/{name}/**` | +| `#/components/parameters/{name}/**` | `#/parameters/{name}/**` | +| `#/components/responses/{name}/**` | `#/responses/{name}/**` | +| `#/components/securitySchemes/{name}/**` | `#/securityDefinitions/{name}/**` | +| `.../responses/{code}/content/{mediaType}/schema/**` | `.../responses/{code}/schema/**` | +| `.../headers/{name}/schema/**` | `.../headers/{name}/**` | + +### Paths With No v2 Equivalent (returns `null`) + +- `#/servers/**` +- `#/webhooks/**` +- `.../callbacks/**` +- `.../links/**` +- `.../requestBody/**` +- `.../content/{mediaType}/encoding/**` +- `#/components/examples/**`, `#/components/headers/**`, `#/components/pathItems/**`, + `#/components/links/**`, `#/components/callbacks/**`, `#/components/requestBodies/**`, + `#/components/mediaTypes/**` + +### Why This Is Experimental + +The set of path transformations may evolve as edge cases are discovered and additional +specification versions are released. The API surface and behavior may change in future versions +based on community feedback. From 6dc21b3780a3fd08fbbb466dd8a5dcb8e27d5287 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 16 Apr 2026 13:55:11 -0400 Subject: [PATCH 3/6] chore: marks new APIs as experimental Signed-off-by: Vincent Biret --- .../Attributes/ExperimentalAttribute.cs | 46 +++++++++++++++++++ .../Services/OpenApiPathHelper.cs | 5 +- .../Validations/OpenApiValidatorError.cs | 6 ++- .../Services/OpenApiPathHelperTests.cs | 1 + 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs diff --git a/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs b/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs new file mode 100644 index 000000000..d58910c7f --- /dev/null +++ b/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +// Polyfill for ExperimentalAttribute which is only available in .NET 8+. +// Since the compiler queries for this attribute by name, having it source-included +// is sufficient for the compiler to recognize it. +namespace System.Diagnostics.CodeAnalysis +{ +#if !NET8_0_OR_GREATER + /// + /// Indicates that an API is experimental and it may change in the future. + /// + /// + /// This attribute allows call sites to be flagged with a diagnostic that indicates that an experimental + /// feature is used. Authors can use this attribute to ship preview features in their assemblies. + /// + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct | + AttributeTargets.Enum | AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Property | + AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Interface | AttributeTargets.Delegate, + Inherited = false)] + internal sealed class ExperimentalAttribute : Attribute + { + /// + /// Initializes a new instance of the class, + /// specifying the ID that the compiler will use when reporting a use of the API. + /// + /// The ID that the compiler will use when reporting a use of the API. + public ExperimentalAttribute(string diagnosticId) + { + DiagnosticId = diagnosticId; + } + + /// + /// Gets the ID that the compiler will use when reporting a use of the API. + /// + public string DiagnosticId { get; } + + /// + /// Gets or sets the URL for corresponding documentation. + /// The API accepts a format string instead of an actual URL, creating a generic URL that includes the diagnostic ID. + /// + public string? UrlFormat { get; set; } + } +#endif +} diff --git a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs index 17effb7da..2315a9b2e 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs @@ -1,14 +1,17 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +#pragma warning disable OAI020 // Internal implementation uses experimental APIs using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.OpenApi; /// /// Provides helper methods for converting OpenAPI JSON Pointer paths between specification versions. /// +[Experimental("OAI020", UrlFormat = "https://aka.ms/openapi/net/experimental/{0}")] public static class OpenApiPathHelper { private static readonly Dictionary _policies = new() diff --git a/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs b/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs index d69018704..5c646db0f 100644 --- a/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs +++ b/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs @@ -1,6 +1,9 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +#pragma warning disable OAI020 // Internal implementation uses experimental APIs +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.OpenApi { /// @@ -29,6 +32,7 @@ public OpenApiValidatorError(string ruleName, string pointer, string message) : /// The equivalent pointer in the target version, the original pointer if no transformation is needed, /// or null if the pointer has no equivalent in the target version. /// + [Experimental("OAI020", UrlFormat = "https://aka.ms/openapi/net/experimental/{0}")] public string? GetVersionedPointer(OpenApiSpecVersion targetVersion) { if (Pointer is null) diff --git a/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs b/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs index f514660d1..f3b8f42f3 100644 --- a/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs +++ b/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. #nullable enable +#pragma warning disable OAI020 // Type is for evaluation purposes only using Xunit; namespace Microsoft.OpenApi.Tests.Services; From 7a6be840cb4d4d5ae0a00debd3db5f894ca76ba8 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 16 Apr 2026 15:20:45 -0400 Subject: [PATCH 4/6] perf: reduce allocations translating paths Signed-off-by: Vincent Biret --- .../IOpenApiPathRepresentationPolicy.cs | 21 +- .../Services/OpenApiPathHelper.cs | 325 +++++++++--------- 2 files changed, 175 insertions(+), 171 deletions(-) diff --git a/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs b/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs index 992e82afe..0c1fb0681 100644 --- a/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs +++ b/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs @@ -3,22 +3,19 @@ namespace Microsoft.OpenApi; /// -/// Defines a policy for matching and transforming OpenAPI JSON Pointer paths +/// Defines a policy for matching and transforming OpenAPI JSON Pointer path segments /// between specification versions. /// internal interface IOpenApiPathRepresentationPolicy { /// - /// Determines whether this policy can handle the given path. + /// Attempts to transform the given path segments to the equivalent in the target version. /// - /// The JSON Pointer path to evaluate. - /// true if this policy applies to the given path; otherwise, false. - bool IsMatch(string path); - - /// - /// Transforms the given path to its equivalent in the target specification version. - /// - /// The JSON Pointer path to transform. - /// The transformed path, or null if the path has no equivalent in the target version. - string? GetVersionedPath(string path); + /// The pre-parsed path segments (without the #/ prefix). + /// + /// When this method returns true, contains the transformed path or null + /// if the path has no equivalent in the target version. + /// + /// true if this policy handled the path; false to try the next policy. + bool TryGetVersionedPath(string[] segments, out string? result); } diff --git a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs index 2315a9b2e..d939c0fa0 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs @@ -52,11 +52,18 @@ public static class OpenApiPathHelper return path; } + // Parse once, share across all policies. + var segments = GetSegments(path); + if (segments.Length == 0) + { + return path; + } + foreach (var policy in matchingPolicies) { - if (policy.IsMatch(path)) + if (policy.TryGetVersionedPath(segments, out var result)) { - return policy.GetVersionedPath(path); + return result; } } @@ -73,37 +80,87 @@ internal static string[] GetSegments(string path) return []; } - ReadOnlySpan span = path.AsSpan(); - if (span.StartsWith("#/".AsSpan(), StringComparison.Ordinal)) - { - span = span.Slice(2); - } - - if (span.IsEmpty) + // Work on the original string directly to avoid an extra allocation from span.ToString(). + var startIndex = path.StartsWith("#/", StringComparison.Ordinal) ? 2 : 0; + if (startIndex >= path.Length) { return []; } - return span.ToString().Split('/'); + return path.Substring(startIndex).Split('/'); } /// - /// Rebuilds a JSON Pointer path from segments. + /// Rebuilds a JSON Pointer path from segments, allocating only one string. /// - internal static string BuildPath(string[] segments) + /// The segment buffer. + /// The number of segments to use from the buffer. + internal static string BuildPath(string[] segments, int length) { - return "#/" + string.Join("/", segments); +#if NET8_0_OR_GREATER + // Pre-calculate total length: "#/" + segments joined by "/" + var totalLength = 2; // "#/" + for (var i = 0; i < length; i++) + { + if (i > 0) + { + totalLength++; // "/" + } + + totalLength += segments[i].Length; + } + + return string.Create(totalLength, (segments, length), static (span, state) => + { + span[0] = '#'; + span[1] = '/'; + var pos = 2; + for (var i = 0; i < state.length; i++) + { + if (i > 0) + { + span[pos++] = '/'; + } + + state.segments[i].AsSpan().CopyTo(span.Slice(pos)); + pos += state.segments[i].Length; + } + }); +#else + var sb = new System.Text.StringBuilder(2 + length * 8); + sb.Append("#/"); + for (var i = 0; i < length; i++) + { + if (i > 0) + { + sb.Append('/'); + } + + sb.Append(segments[i]); + } + + return sb.ToString(); +#endif } /// - /// Removes segments at the specified indices by building a new array without them. + /// Copies segments into the target buffer, skipping a contiguous range. + /// Returns the number of segments written. /// - internal static string[] RemoveSegments(string[] segments, int startIndex, int count) + internal static int CopySkipping(string[] source, int sourceLength, string[] target, int skipStart, int skipCount) { - var result = new string[segments.Length - count]; - Array.Copy(segments, 0, result, 0, startIndex); - Array.Copy(segments, startIndex + count, result, startIndex, segments.Length - startIndex - count); - return result; + var written = 0; + for (var i = 0; i < sourceLength; i++) + { + if (i >= skipStart && i < skipStart + skipCount) + { + continue; + } + + target[written++] = source[i]; + } + + return written; } } @@ -125,22 +182,27 @@ internal sealed class V2UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy OpenApiConstants.MediaTypes, }; - public bool IsMatch(string path) + // Segments that are always unsupported regardless of position (except index 0 which is checked separately). + private static readonly HashSet UnsupportedSegments = new(StringComparer.Ordinal) + { + OpenApiConstants.Servers, + OpenApiConstants.Callbacks, + OpenApiConstants.Links, + OpenApiConstants.RequestBody, + }; + + public bool TryGetVersionedPath(string[] segments, out string? result) { - var segments = OpenApiPathHelper.GetSegments(path); + result = null; + if (segments.Length == 0) { return false; } - // Top-level servers: #/servers/** - if (string.Equals(segments[0], OpenApiConstants.Servers, StringComparison.Ordinal)) - { - return true; - } - - // Top-level webhooks: #/webhooks/** - if (string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal)) + // Top-level: #/servers/** or #/webhooks/** + if (string.Equals(segments[0], OpenApiConstants.Servers, StringComparison.Ordinal) || + string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal)) { return true; } @@ -153,31 +215,13 @@ public bool IsMatch(string path) return true; } - // Walk through segments looking for v3-only constructs in context + // Walk through segments looking for v3-only constructs for (var i = 1; i < segments.Length; i++) { var segment = segments[i]; - // servers at any nested level (path-item/operation level) - if (string.Equals(segment, OpenApiConstants.Servers, StringComparison.Ordinal)) - { - return true; - } - - // callbacks at operation level - if (string.Equals(segment, OpenApiConstants.Callbacks, StringComparison.Ordinal)) - { - return true; - } - - // links at response level - if (string.Equals(segment, OpenApiConstants.Links, StringComparison.Ordinal)) - { - return true; - } - - // inline requestBody at operation level - if (string.Equals(segment, OpenApiConstants.RequestBody, StringComparison.Ordinal)) + // servers, callbacks, links, requestBody at any nested level + if (UnsupportedSegments.Contains(segment)) { return true; } @@ -193,8 +237,6 @@ public bool IsMatch(string path) return false; } - - public string? GetVersionedPath(string path) => null; } /// @@ -203,23 +245,23 @@ public bool IsMatch(string path) /// internal sealed class V3_0UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy { - public bool IsMatch(string path) + public bool TryGetVersionedPath(string[] segments, out string? result) { - var segments = OpenApiPathHelper.GetSegments(path); - if (segments.Length == 0) + result = null; + + if (segments.Length > 0 && + string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal)) { - return false; + return true; } - // webhooks were added in v3.1 - return string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal); + return false; } - - public string? GetVersionedPath(string path) => null; } /// -/// Renames v3 component paths to their v2 equivalents. +/// Renames v3 component paths to their v2 equivalents and applies nested transformations +/// (content unwrapping, header schema unwrapping) in a single pass. /// /// #/components/schemas/{name}/**#/definitions/{name}/** /// #/components/parameters/{name}/**#/parameters/{name}/** @@ -237,38 +279,49 @@ internal sealed class V2ComponentRenamePolicy : IOpenApiPathRepresentationPolicy [OpenApiConstants.SecuritySchemes] = OpenApiConstants.SecurityDefinitions, }; - public bool IsMatch(string path) + public bool TryGetVersionedPath(string[] segments, out string? result) { - var segments = OpenApiPathHelper.GetSegments(path); + result = null; - return segments.Length >= 2 && - string.Equals(segments[0], OpenApiConstants.Components, StringComparison.Ordinal) && - ComponentMappings.ContainsKey(segments[1]); - } + if (segments.Length < 2 || + !string.Equals(segments[0], OpenApiConstants.Components, StringComparison.Ordinal) || + !ComponentMappings.TryGetValue(segments[1], out var v2Name)) + { + return false; + } - public string? GetVersionedPath(string path) - { - var segments = OpenApiPathHelper.GetSegments(path); - var v2Name = ComponentMappings[segments[1]]; + // Build the transformed path in one pass: + // - Skip "components" (index 0), replace component type (index 1) with v2 name + // - Apply content unwrapping and header schema unwrapping inline + var buffer = new string[segments.Length]; // upper bound + var written = 0; + buffer[written++] = v2Name; - // Remove "components" (index 0) and replace the component type name (index 1) - segments[1] = v2Name; - var result = OpenApiPathHelper.RemoveSegments(segments, 0, 1); + for (var i = 2; i < segments.Length; i++) + { + // Content unwrapping: skip "content" and "{mediaType}" after "responses/{code}" + if (string.Equals(segments[i], OpenApiConstants.Content, StringComparison.Ordinal) && + i >= 3 && + string.Equals(segments[i - 2], OpenApiConstants.Responses, StringComparison.Ordinal) && + i + 1 < segments.Length) + { + i++; // skip mediaType too + continue; + } - // Apply further transformations to the result (e.g., schema unwrapping within components) - var resultPath = OpenApiPathHelper.BuildPath(result); - return ApplyNestedTransformations(resultPath); - } + // Header schema unwrapping: skip "schema" after "headers/{name}" + if (string.Equals(segments[i], OpenApiConstants.Schema, StringComparison.Ordinal) && + i >= 3 && + string.Equals(segments[i - 2], OpenApiConstants.Headers, StringComparison.Ordinal)) + { + continue; + } - private static string? ApplyNestedTransformations(string path) - { - // After renaming, a component response might still have content/{mt}/schema - // e.g. #/components/responses/NotFound/content/application~1json/schema → - // #/responses/NotFound/schema (needs content unwrapping) - var segments = OpenApiPathHelper.GetSegments(path); - segments = V2ResponseContentUnwrappingPolicy.UnwrapContentSegments(segments); - segments = V2HeaderSchemaUnwrappingPolicy.UnwrapHeaderSchemaSegments(segments); - return OpenApiPathHelper.BuildPath(segments); + buffer[written++] = segments[i]; + } + + result = OpenApiPathHelper.BuildPath(buffer, written); + return true; } } @@ -278,55 +331,32 @@ public bool IsMatch(string path) /// internal sealed class V2ResponseContentUnwrappingPolicy : IOpenApiPathRepresentationPolicy { - public bool IsMatch(string path) + public bool TryGetVersionedPath(string[] segments, out string? result) { - var segments = OpenApiPathHelper.GetSegments(path); - return FindContentIndex(segments) >= 0; - } + result = null; - public string? GetVersionedPath(string path) - { - var segments = OpenApiPathHelper.GetSegments(path); - segments = UnwrapContentSegments(segments); - return OpenApiPathHelper.BuildPath(segments); - } - - /// - /// Finds the "content" segment that follows a "responses/{code}" sequence. - /// Returns the index of "content", or -1 if not found. - /// - private static int FindContentIndex(string[] segments) - { - // Look for: responses / {code} / content / {mediaType} + // Find: responses / {code} / content / {mediaType} + var contentIndex = -1; for (var i = 0; i < segments.Length - 3; i++) { if (string.Equals(segments[i], OpenApiConstants.Responses, StringComparison.Ordinal) && string.Equals(segments[i + 2], OpenApiConstants.Content, StringComparison.Ordinal)) { - return i + 2; + contentIndex = i + 2; + break; } } - return -1; - } - - /// - /// Removes the "content" and "{mediaType}" segments from a response path. - /// - internal static string[] UnwrapContentSegments(string[] segments) - { - // Look for: responses / {code} / content / {mediaType} / ... - for (var i = 0; i < segments.Length - 3; i++) + if (contentIndex < 0) { - if (string.Equals(segments[i], OpenApiConstants.Responses, StringComparison.Ordinal) && - string.Equals(segments[i + 2], OpenApiConstants.Content, StringComparison.Ordinal)) - { - // Remove "content" (i+2) and "{mediaType}" (i+3) - return OpenApiPathHelper.RemoveSegments(segments, i + 2, 2); - } + return false; } - return segments; + // Remove "content" and "{mediaType}" — copy segments skipping those two + var buffer = new string[segments.Length - 2]; + var written = OpenApiPathHelper.CopySkipping(segments, segments.Length, buffer, contentIndex, 2); + result = OpenApiPathHelper.BuildPath(buffer, written); + return true; } } @@ -336,54 +366,31 @@ internal static string[] UnwrapContentSegments(string[] segments) /// internal sealed class V2HeaderSchemaUnwrappingPolicy : IOpenApiPathRepresentationPolicy { - public bool IsMatch(string path) + public bool TryGetVersionedPath(string[] segments, out string? result) { - var segments = OpenApiPathHelper.GetSegments(path); - return FindHeaderSchemaIndex(segments) >= 0; - } + result = null; - public string? GetVersionedPath(string path) - { - var segments = OpenApiPathHelper.GetSegments(path); - segments = UnwrapHeaderSchemaSegments(segments); - return OpenApiPathHelper.BuildPath(segments); - } - - /// - /// Finds the "schema" segment that follows a "headers/{name}" sequence. - /// Returns the index of "schema", or -1 if not found. - /// - private static int FindHeaderSchemaIndex(string[] segments) - { - // Look for: headers / {name} / schema + // Find: headers / {name} / schema + var schemaIndex = -1; for (var i = 0; i < segments.Length - 2; i++) { if (string.Equals(segments[i], OpenApiConstants.Headers, StringComparison.Ordinal) && string.Equals(segments[i + 2], OpenApiConstants.Schema, StringComparison.Ordinal)) { - return i + 2; + schemaIndex = i + 2; + break; } } - return -1; - } - - /// - /// Removes the "schema" segment from a header path. - /// - internal static string[] UnwrapHeaderSchemaSegments(string[] segments) - { - // Look for: headers / {name} / schema - for (var i = 0; i < segments.Length - 2; i++) + if (schemaIndex < 0) { - if (string.Equals(segments[i], OpenApiConstants.Headers, StringComparison.Ordinal) && - string.Equals(segments[i + 2], OpenApiConstants.Schema, StringComparison.Ordinal)) - { - // Remove "schema" (i+2) - return OpenApiPathHelper.RemoveSegments(segments, i + 2, 1); - } + return false; } - return segments; + // Remove "schema" — copy segments skipping that one + var buffer = new string[segments.Length - 1]; + var written = OpenApiPathHelper.CopySkipping(segments, segments.Length, buffer, schemaIndex, 1); + result = OpenApiPathHelper.BuildPath(buffer, written); + return true; } } From d3dd449a9f797513d0e2257cfd6a67dc969dac47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:14:14 +0000 Subject: [PATCH 5/6] fix(library): replace foreach with LINQ Any to address CodeQL missed-where finding Agent-Logs-Url: https://github.com/microsoft/OpenAPI.NET/sessions/4f39d2a9-62e7-497f-be81-7f0efcb876c9 Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs index d939c0fa0..dbea21a39 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace Microsoft.OpenApi; @@ -59,12 +60,10 @@ public static class OpenApiPathHelper return path; } - foreach (var policy in matchingPolicies) + string? versionedPath = null; + if (matchingPolicies.Any(policy => policy.TryGetVersionedPath(segments, out versionedPath))) { - if (policy.TryGetVersionedPath(segments, out var result)) - { - return result; - } + return versionedPath; } return path; From c46f13c58bffe75669312565feb808787fc5572c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:25:18 +0000 Subject: [PATCH 6/6] style(library): move conditional compilation directives above namespace and rename V3_0 to V30 Agent-Logs-Url: https://github.com/microsoft/OpenAPI.NET/sessions/0fac65d3-0bbb-44db-b82e-7648c8fb8f86 Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs | 4 ++-- src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs b/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs index d58910c7f..83446b6b2 100644 --- a/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs +++ b/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs @@ -4,9 +4,9 @@ // Polyfill for ExperimentalAttribute which is only available in .NET 8+. // Since the compiler queries for this attribute by name, having it source-included // is sufficient for the compiler to recognize it. +#if !NET8_0_OR_GREATER namespace System.Diagnostics.CodeAnalysis { -#if !NET8_0_OR_GREATER /// /// Indicates that an API is experimental and it may change in the future. /// @@ -42,5 +42,5 @@ public ExperimentalAttribute(string diagnosticId) /// public string? UrlFormat { get; set; } } -#endif } +#endif diff --git a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs index dbea21a39..1f41b326c 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs @@ -27,7 +27,7 @@ public static class OpenApiPathHelper ], [OpenApiSpecVersion.OpenApi3_0] = [ - new V3_0UnsupportedPathPolicy(), + new V30UnsupportedPathPolicy(), ], }; @@ -242,7 +242,7 @@ public bool TryGetVersionedPath(string[] segments, out string? result) /// Returns null for paths that have no equivalent in OpenAPI v3.0. /// Covers: webhooks (added in v3.1). /// -internal sealed class V3_0UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy +internal sealed class V30UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy { public bool TryGetVersionedPath(string[] segments, out string? result) {