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. diff --git a/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs b/src/Microsoft.OpenApi/Attributes/ExperimentalAttribute.cs new file mode 100644 index 000000000..83446b6b2 --- /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. +#if !NET8_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + /// + /// 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/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..0c1fb0681 --- /dev/null +++ b/src/Microsoft.OpenApi/Services/IOpenApiPathRepresentationPolicy.cs @@ -0,0 +1,21 @@ +// 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 path segments +/// between specification versions. +/// +internal interface IOpenApiPathRepresentationPolicy +{ + /// + /// Attempts to transform the given path segments to the equivalent in the target version. + /// + /// 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 new file mode 100644 index 000000000..1f41b326c --- /dev/null +++ b/src/Microsoft.OpenApi/Services/OpenApiPathHelper.cs @@ -0,0 +1,395 @@ +// 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; +using System.Linq; + +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() + { + [OpenApiSpecVersion.OpenApi2_0] = + [ + // Order matters: null policies first, then transformations. + new V2UnsupportedPathPolicy(), + new V2ComponentRenamePolicy(), + new V2ResponseContentUnwrappingPolicy(), + new V2HeaderSchemaUnwrappingPolicy(), + ], + [OpenApiSpecVersion.OpenApi3_0] = + [ + new V30UnsupportedPathPolicy(), + ], + }; + + /// + /// 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; + } + + // Parse once, share across all policies. + var segments = GetSegments(path); + if (segments.Length == 0) + { + return path; + } + + string? versionedPath = null; + if (matchingPolicies.Any(policy => policy.TryGetVersionedPath(segments, out versionedPath))) + { + return versionedPath; + } + + 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 []; + } + + // 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 path.Substring(startIndex).Split('/'); + } + + /// + /// Rebuilds a JSON Pointer path from segments, allocating only one string. + /// + /// The segment buffer. + /// The number of segments to use from the buffer. + internal static string BuildPath(string[] segments, int length) + { +#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 + } + + /// + /// Copies segments into the target buffer, skipping a contiguous range. + /// Returns the number of segments written. + /// + internal static int CopySkipping(string[] source, int sourceLength, string[] target, int skipStart, int skipCount) + { + var written = 0; + for (var i = 0; i < sourceLength; i++) + { + if (i >= skipStart && i < skipStart + skipCount) + { + continue; + } + + target[written++] = source[i]; + } + + return written; + } +} + +/// +/// 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, + }; + + // 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) + { + result = null; + + if (segments.Length == 0) + { + return false; + } + + // Top-level: #/servers/** or #/webhooks/** + if (string.Equals(segments[0], OpenApiConstants.Servers, StringComparison.Ordinal) || + 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 + for (var i = 1; i < segments.Length; i++) + { + var segment = segments[i]; + + // servers, callbacks, links, requestBody at any nested level + if (UnsupportedSegments.Contains(segment)) + { + 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; + } +} + +/// +/// Returns null for paths that have no equivalent in OpenAPI v3.0. +/// Covers: webhooks (added in v3.1). +/// +internal sealed class V30UnsupportedPathPolicy : IOpenApiPathRepresentationPolicy +{ + public bool TryGetVersionedPath(string[] segments, out string? result) + { + result = null; + + if (segments.Length > 0 && + string.Equals(segments[0], OpenApiConstants.Webhooks, StringComparison.Ordinal)) + { + return true; + } + + return false; + } +} + +/// +/// 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}/** +/// #/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 TryGetVersionedPath(string[] segments, out string? result) + { + result = null; + + if (segments.Length < 2 || + !string.Equals(segments[0], OpenApiConstants.Components, StringComparison.Ordinal) || + !ComponentMappings.TryGetValue(segments[1], out var v2Name)) + { + return false; + } + + // 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; + + 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; + } + + // 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; + } + + buffer[written++] = segments[i]; + } + + result = OpenApiPathHelper.BuildPath(buffer, written); + return true; + } +} + +/// +/// 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 TryGetVersionedPath(string[] segments, out string? result) + { + result = null; + + // 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)) + { + contentIndex = i + 2; + break; + } + } + + if (contentIndex < 0) + { + return false; + } + + // 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; + } +} + +/// +/// 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 TryGetVersionedPath(string[] segments, out string? result) + { + result = null; + + // 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)) + { + schemaIndex = i + 2; + break; + } + } + + if (schemaIndex < 0) + { + return false; + } + + // 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; + } +} diff --git a/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs b/src/Microsoft.OpenApi/Validations/OpenApiValidatorError.cs index b42e05d0c..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 { /// @@ -20,5 +23,24 @@ 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. + /// + [Experimental("OAI020", UrlFormat = "https://aka.ms/openapi/net/experimental/{0}")] + 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..f3b8f42f3 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Services/OpenApiPathHelperTests.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +#nullable enable +#pragma warning disable OAI020 // Type is for evaluation purposes only +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 +}