diff --git a/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md b/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md index 526d370..8d9098e 100644 --- a/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md +++ b/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md @@ -20,3 +20,6 @@ Complements: [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) t |--:|:-:|---| |`IApplicationBuilder`|⬇️|`UseRestfulApiVersioning`| |`IServiceCollection`|⬇️|`AddRestfulApiVersioning`| +|`IServiceCollection`|⬇️|`AddApiVersionParser`| + +Use `AddApiVersionParser` to register a custom `IApiVersionParser` — for example, an `ApiVersionAliasParser` built with `CreateSemanticVersionAlias` that maps short version tokens to canonical `SemanticApiVersion` instances. diff --git a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ApiVersionAliasParser.md b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ApiVersionAliasParser.md new file mode 100644 index 0000000..ee37daf --- /dev/null +++ b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ApiVersionAliasParser.md @@ -0,0 +1,32 @@ +--- +uid: Codebelt.Extensions.Asp.Versioning.ApiVersionAliasParser +example: +- *content +--- +Use `ApiVersionAliasParser` when callers send short version tokens such as `1`, `1.2`, or `1.2.3` over the wire but the rest of the application works with canonical `SemanticApiVersion` instances. The static `CreateSemanticVersionAlias` factory generates the alias map for a set of semantic versions, and `Parse` resolves a request token to the canonical version while the parser falls back to `ApiVersionParser.Default` for anything outside the alias table. The example below parses `1.2` and `2.0` from request strings and returns the resolved semantic versions. + +```csharp +using System; +using System.Collections.Generic; +using Asp.Versioning; +using Codebelt.Extensions.Asp.Versioning; + +namespace Codebelt.Extensions.Asp.Versioning; + +public class AliasAwareVersionResolver +{ + public IReadOnlyList ResolveShortVersionTokens() + { + var stable = new SemanticApiVersion(1, 2, 0); + var next = new SemanticApiVersion(2, 0, 0); + + IApiVersionParser parser = ApiVersionAliasParser.CreateSemanticVersionAlias([stable, next]); + + return new[] + { + parser.Parse("1.2").ToString(), + parser.Parse("2.0").ToString() + }; + } +} +``` diff --git a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions.md b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions.md index 7b1d3a6..b31134d 100644 --- a/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions.md +++ b/.docfx/api/types/Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions.md @@ -3,9 +3,10 @@ uid: Codebelt.Extensions.Asp.Versioning.ServiceCollectionExtensions example: - *content --- -Call `AddRestfulApiVersioning` on `IServiceCollection` to wire up `Asp.Versioning` for a RESTful API in a single registration. The extension combines `AddApiVersioning`, `AddMvc`, and `AddApiExplorer`, installs `RestfulApiVersionReader` to parse the version from a filtered set of `Accept` media types, and registers problem-details integration that translates version-resolution errors into `HttpStatusCodeException` values compatible with the rest of the pipeline. Configure the default version, parameter name, accepted media types, version selector strategy, and problem-details style through the optional `setup` delegate. +Call `AddRestfulApiVersioning` on `IServiceCollection` to wire up `Asp.Versioning` for a RESTful API in a single registration. The extension combines `AddApiVersioning`, `AddMvc`, and `AddApiExplorer`, installs `RestfulApiVersionReader` to parse the version from a filtered set of `Accept` media types, and registers problem-details integration that translates version-resolution errors into `HttpStatusCodeException` values compatible with the rest of the pipeline. When the application also needs short version tokens such as `1` or `1.2` to map to canonical `SemanticApiVersion` values, follow up with `AddApiVersionParser` and an `ApiVersionAliasParser` built from the same set of versions. Configure the default version, parameter name, accepted media types, version selector strategy, and problem-details style through the optional `setup` delegate. ```csharp +using System.Collections.Generic; using Asp.Versioning; using Codebelt.Extensions.Asp.Versioning; using Microsoft.Extensions.DependencyInjection; @@ -16,13 +17,18 @@ public class RestfulApiVersioningServiceRegistration { public static void ConfigureServices(IServiceCollection services) { + var stable = new SemanticApiVersion(1, 0, 0); + var next = new SemanticApiVersion(2, 0, 0); + services.AddRestfulApiVersioning(options => { - options.DefaultApiVersion = new ApiVersion(1, 0); + options.DefaultApiVersion = stable; options.ParameterName = "version"; options.ReportApiVersions = true; options.UseApiVersionSelector(); }); + + services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias([stable, next])); } } ``` diff --git a/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt b/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt index 1eda191..b957300 100644 --- a/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt @@ -1,3 +1,13 @@ +Version: 10.2.1 +Availability: .NET 10 and .NET 9 + +# Improvements +- CHANGED RestfulApiVersioningOptions to include ApiVersionReader property for advanced version reader customization, +- CHANGED Automatic version normalization is enabled by default when the default API version is a SemanticApiVersion; otherwise it retains its previous behavior. + +# Bug Fixes +- FIXED RestfulApiVersionReader to normalize semantically equivalent API version formats (1, 1.0, 1.0.0) to identical values for consistent routing and version matching, + Version: 10.2.0 Availability: .NET 10 and .NET 9 diff --git a/AGENTS.md b/AGENTS.md index 3defeee..4a9ee91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,9 @@ Codebelt.Extensions.Asp.Versioning is a .NET library providing uniform, opiniona - `src/` — Production source code - `Codebelt.Extensions.Asp.Versioning/` — Core API versioning library with middleware, options, and parsers - - `Codebelt.Extensions.Asp.Versioning.FunctionalTests/` — End-to-end functional tests for integration scenarios - `test/` — Unit tests (project names end with `Tests`) - `Codebelt.Extensions.Asp.Versioning.Tests/` — Unit tests for core functionality + - `Codebelt.Extensions.Asp.Versioning.FunctionalTests/` — End-to-end functional tests for integration scenarios - `.nuget/` — Per-package NuGet metadata (icon, README, release notes) - `.docfx/` — DocFX documentation configuration - `.github/` — CI/CD workflows, contributing guidelines, Copilot instructions diff --git a/CHANGELOG.md b/CHANGELOG.md index 361d874..6c34e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba > [!NOTE] > Changelog entries prior to version 8.4.0 was migrated from previous versions of Cuemon.Extensions.Asp.Versioning. +## [10.2.1] - 2026-07-09 + +This is a minor release focused on normalizing semantically equivalent API version formats to ensure consistent routing and version matching. Version strings like 1, 1.0, and 1.0.0 are now normalized to identical canonical forms, with automatic enabling when using `SemanticApiVersion` as the default API version. + +### Changed + +- `RestfulApiVersioningOptions` includes a new `ApiVersionReader` property for advanced version reader customization, +- Automatic version normalization is enabled by default when the default API version is a `SemanticApiVersion`; otherwise it retains its previous behavior. + +### Fixed + +- `RestfulApiVersionReader` now normalizes semantically equivalent API version formats (1, 1.0, 1.0.0) to identical values for consistent routing and version matching. + ## [10.2.0] - 2026-07-07 This is a minor release adding API version aliasing support and automatic semantic version alias registration. Callers can now request versions by shortened or compatibility-oriented tokens (e.g., `1`, `1.0`, `1.0.0`), and applications using `SemanticApiVersion` as their default API version benefit from automatic alias registration without explicit configuration. @@ -180,7 +193,8 @@ This major release is first and foremost focused on ironing out any wrinkles tha - RestfulApiVersionReader class in the Codebelt.Extensions.Asp.Versioning namespace that represents a RESTful API version reader that reads the value from a filtered list of HTTP Accept headers in the request - RestfulProblemDetailsFactory class in the Codebelt.Extensions.Asp.Versioning namespace that represents a RESTful implementation of the IProblemDetailsFactory which throws variants of HttpStatusCodeException that needs to be translated accordingly -[Unreleased]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.0...HEAD +[Unreleased]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.1...HEAD +[10.2.1]: https://github.com/codebeltnet/asp-versioning/compare/v10.2.0...v10.2.1 [10.2.0]: https://github.com/codebeltnet/asp-versioning/compare/v10.1.0...v10.2.0 [10.1.0]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.9...v10.1.0 [10.0.9]: https://github.com/codebeltnet/asp-versioning/compare/v10.0.8...v10.0.9 diff --git a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs index 2281de2..e2703a4 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; namespace Codebelt.Extensions.Asp.Versioning @@ -28,6 +30,46 @@ public RestfulApiVersionReader(IEnumerable validAcceptHeaders, string pa /// The valid accept headers that will filter by. public IList ValidAcceptHeaders { get; } + internal bool PreviousBehavior { get; set; } + + /// + /// Reads the requested API version from the HTTP request. + /// + /// The HTTP request to read from. + /// The requested API versions. + public override IReadOnlyList Read(HttpRequest request) + { + var versions = base.Read(request); + if (PreviousBehavior || versions.Count <= 1) + { + return versions; + } + + var parser = request.HttpContext.RequestServices.GetService(); + if (parser == null) + { + return versions; + } + + var normalizedVersions = new List(versions.Count); + var seenVersions = new HashSet(StringComparer.Ordinal); + foreach (var version in versions) + { + if (!parser.TryParse(version, out var apiVersion) || apiVersion == null) + { + return versions; + } + + var normalizedVersion = apiVersion.ToString(); + if (seenVersions.Add(normalizedVersion)) + { + normalizedVersions.Add(normalizedVersion); + } + } + + return normalizedVersions.Count == versions.Count ? versions : normalizedVersions; + } + /// /// Reads the requested API version from the HTTP Accept header. /// diff --git a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs index b50ddba..15d2533 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs @@ -51,6 +51,10 @@ public class RestfulApiVersioningOptions : IValidatableParameterObject /// /// application/json, application/xml, application/yaml, application/vnd, text/json, text/xml, text/plain, text/yaml, */* /// + /// + /// + /// null + /// /// /// public RestfulApiVersioningOptions() @@ -97,6 +101,12 @@ public RestfulApiVersioningOptions UseApiVersionSelector() where T : class, I return this; } + /// + /// Gets or sets the API version reader used to read the API version from the request. + /// + /// The API version reader used to read the API version from the request. + public IApiVersionReader ApiVersionReader { get; set; } + /// /// Gets the concrete implementation type of a type that implements the interface. /// diff --git a/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs b/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs index 9ead1c1..d8bc6fa 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs @@ -26,12 +26,18 @@ public static IServiceCollection AddRestfulApiVersioning(this IServiceCollection Validator.ThrowIfNull(services); Validator.ThrowIfInvalidConfigurator(setup, out var options); + var defaultSemanticApiVersion = options.DefaultApiVersion as SemanticApiVersion; + var preferSemVerBehavior = defaultSemanticApiVersion != null; + services.AddApiVersioning(o => { o.DefaultApiVersion = options.DefaultApiVersion; o.ReportApiVersions = options.ReportApiVersions; o.AssumeDefaultVersionWhenUnspecified = true; - o.ApiVersionReader = new RestfulApiVersionReader(options.ValidAcceptHeaders, options.ParameterName); + o.ApiVersionReader = options.ApiVersionReader ?? new RestfulApiVersionReader(options.ValidAcceptHeaders, options.ParameterName) + { + PreviousBehavior = !preferSemVerBehavior + }; o.ApiVersionSelector = (Activator.CreateInstance(options.ApiVersionSelectorType, o) as IApiVersionSelector)!; }).AddMvc(o => { @@ -61,9 +67,9 @@ public static IServiceCollection AddRestfulApiVersioning(this IServiceCollection }); } - if (options.DefaultApiVersion is SemanticApiVersion semver) + if (defaultSemanticApiVersion != null) { - services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias(semver)); + services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias(defaultSemanticApiVersion)); } return services; diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs index a45f148..3c8236a 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs @@ -97,6 +97,25 @@ public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemantic Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); } + [Fact] + public async Task PostRequest_ShouldFailWhenPlainApiVersionUsesDifferentAliasTextAcrossAcceptAndContentType() + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest("1", "1.0.0")); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine("Accept v=1; Content-Type v=1.0.0"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); + } + private static IResult WriteResponse(HttpContext context, ApiVersion version) { context.Response.Headers[RequestedVersionHeaderName] = version.ToString(); @@ -104,16 +123,21 @@ private static IResult WriteResponse(HttpContext context, ApiVersion version) } private static HttpRequestMessage CreateRequest(string? version) + { + return CreateRequest(version, version); + } + + private static HttpRequestMessage CreateRequest(string? acceptVersion, string? contentTypeVersion) { var request = new HttpRequestMessage(HttpMethod.Post, "/requests") { Content = new StringContent("""{"payload":"request"}""", Encoding.UTF8, "application/json") }; - request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}"); - if (version is not null) + request.Headers.Add("Accept", acceptVersion is null ? "application/json" : $"application/json;v={acceptVersion}"); + if (contentTypeVersion is not null) { - request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", version)); + request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", contentTypeVersion)); } return request; diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs index ad839d3..728c63f 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs @@ -90,6 +90,72 @@ public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemantic Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); } + [Theory] + [InlineData("1", "1")] + [InlineData("1", "1.0")] + [InlineData("1", "1.0.0")] + [InlineData("1.0", "1")] + [InlineData("1.0", "1.0")] + [InlineData("1.0", "1.0.0")] + [InlineData("1.0.0", "1")] + [InlineData("1.0.0", "1.0")] + [InlineData("1.0.0", "1.0.0")] + public async Task PostRequest_ShouldRouteWhenAcceptVersionAliasAndContentTypeVersionAreEquivalent(string acceptVersion, string contentTypeVersion) + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest(acceptVersion, contentTypeVersion)); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine($"Accept v={acceptVersion}; Content-Type v={contentTypeVersion}"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName)); + } + + [Fact] + public async Task PostRequest_ShouldFailWhenAcceptVersionAndContentTypeVersionAreDifferent() + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest("1", "2.0.0")); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine("Accept v=1; Content-Type v=2.0.0"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); + } + + [Fact] + public async Task PostRequest_ShouldFailWhenRequestedVersionIsNotARegisteredSemanticAlias() + { + using var client = Host.GetTestClient(); + using var response = await client.SendAsync(CreateRequest("1.1")); + + var payload = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine("Accept v=1.1; Content-Type v=1.1"); + TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}"); + if (!string.IsNullOrWhiteSpace(payload)) + { + TestOutput.WriteLine(payload); + } + + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + Assert.False(response.Headers.Contains(RequestedVersionHeaderName)); + } + private static IResult WriteResponse(HttpContext context, ApiVersion version) { context.Response.Headers[RequestedVersionHeaderName] = version.ToString(); @@ -97,16 +163,21 @@ private static IResult WriteResponse(HttpContext context, ApiVersion version) } private static HttpRequestMessage CreateRequest(string? version) + { + return CreateRequest(version, version); + } + + private static HttpRequestMessage CreateRequest(string? acceptVersion, string? contentTypeVersion) { var request = new HttpRequestMessage(HttpMethod.Post, "/requests") { Content = new StringContent("""{"payload":"request"}""", Encoding.UTF8, "application/json") }; - request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}"); - if (version is not null) + request.Headers.Add("Accept", acceptVersion is null ? "application/json" : $"application/json;v={acceptVersion}"); + if (contentTypeVersion is not null) { - request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", version)); + request.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", contentTypeVersion)); } return request;