Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ Complements: [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) t
|--:|:-:|---|
|`IApplicationBuilder`|⬇️|`UseRestfulApiVersioning`|
|`IServiceCollection`|⬇️|`AddRestfulApiVersioning`|
|`IServiceCollection`|⬇️|`AddApiVersionParser<T>`|

Use `AddApiVersionParser` to register a custom `IApiVersionParser` — for example, an `ApiVersionAliasParser` built with `CreateSemanticVersionAlias` that maps short version tokens to canonical `SemanticApiVersion` instances.
Original file line number Diff line number Diff line change
@@ -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<string> 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()
};
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<LowestImplementedApiVersionSelector>();
});

services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias([stable, next]));
}
}
```
10 changes: 10 additions & 0 deletions .nuget/Codebelt.Extensions.Asp.Versioning/PackageReleaseNotes.txt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +30,46 @@ public RestfulApiVersionReader(IEnumerable<string> validAcceptHeaders, string pa
/// <value>The valid accept headers that <see cref="ReadAcceptHeader"/> will filter by.</value>
public IList<string> ValidAcceptHeaders { get; }

internal bool PreviousBehavior { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 PreviousBehavior is internal but release notes describe it as a user-facing opt-out

The PackageReleaseNotes.txt and CHANGELOG.md both state "a PreviousBehavior compatibility flag available for opting out of the normalization," implying consumers can set it. Because the property has internal visibility, code outside the assembly cannot access it, so there is no public way to disable normalization on a user-constructed RestfulApiVersionReader. Additionally, since PreviousBehavior defaults to false, any RestfulApiVersionReader constructed by a caller and supplied via RestfulApiVersioningOptions.ApiVersionReader will silently have normalization enabled with no escape hatch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs
Line: 33

Comment:
`PreviousBehavior` is `internal` but release notes describe it as a user-facing opt-out

The `PackageReleaseNotes.txt` and `CHANGELOG.md` both state "a `PreviousBehavior` compatibility flag available for opting out of the normalization," implying consumers can set it. Because the property has `internal` visibility, code outside the assembly cannot access it, so there is no public way to disable normalization on a user-constructed `RestfulApiVersionReader`. Additionally, since `PreviousBehavior` defaults to `false`, any `RestfulApiVersionReader` constructed by a caller and supplied via `RestfulApiVersioningOptions.ApiVersionReader` will silently have normalization enabled with no escape hatch.

How can I resolve this? If you propose a fix, please make it concise.


/// <summary>
/// Reads the requested API version from the HTTP request.
/// </summary>
/// <param name="request">The HTTP request to read from.</param>
/// <returns>The requested API versions.</returns>
public override IReadOnlyList<string> Read(HttpRequest request)
{
var versions = base.Read(request);
if (PreviousBehavior || versions.Count <= 1)
{
return versions;
}

var parser = request.HttpContext.RequestServices.GetService<IApiVersionParser>();
if (parser == null)
{
return versions;
}

var normalizedVersions = new List<string>(versions.Count);
var seenVersions = new HashSet<string>(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;
}

/// <summary>
/// Reads the requested API version from the HTTP Accept header.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public class RestfulApiVersioningOptions : IValidatableParameterObject
/// <term><see cref="ValidAcceptHeaders"/></term>
/// <description><c>application/json, application/xml, application/yaml, application/vnd, text/json, text/xml, text/plain, text/yaml, */*</c></description>
/// </item>
/// <item>
/// <term><see cref="ApiVersionReader"/></term>
/// <description><c>null</c></description>
/// </item>
/// </list>
/// </remarks>
public RestfulApiVersioningOptions()
Expand Down Expand Up @@ -97,6 +101,12 @@ public RestfulApiVersioningOptions UseApiVersionSelector<T>() where T : class, I
return this;
}

/// <summary>
/// Gets or sets the API version reader used to read the API version from the request.
/// </summary>
/// <value>The API version reader used to read the API version from the request.</value>
public IApiVersionReader ApiVersionReader { get; set; }

/// <summary>
/// Gets the concrete implementation type of a type that implements the <see cref="IApiVersionSelector"/> interface.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
{
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,23 +97,47 @@ 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();
return Results.NoContent();
}

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,23 +90,94 @@ 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();
return Results.NoContent();
}

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;
Expand Down
Loading