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
+}