Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
uid: Codebelt.Extensions.Asp.Versioning
summary: *content
---
Use this namespace when building versioned ASP.NET Core RESTful APIs that accept the API version from the HTTP `Accept` header — a common pattern for APIs consumed by real browsers and heterogeneous clients — without writing bespoke middleware to filter browser MIME types or translate Asp.Versioning error codes into structured exception types. It also provides semantic API version metadata for APIs whose public contract needs `major.minor.patch`, pre-release, and build metadata while still participating in the `Asp.Versioning.ApiVersion` model.
Use this namespace when building versioned ASP.NET Core RESTful APIs that accept the API version from the HTTP `Accept` header — a common pattern for APIs consumed by real browsers and heterogeneous clients — without writing bespoke middleware to filter browser MIME types or translate Asp.Versioning error codes into structured exception types. It also provides semantic API version metadata for APIs whose public contract needs `major.minor.patch`, pre-release, and build metadata while still participating in the `Asp.Versioning.ApiVersion` model.

RESTful API versioning built on `Asp.Versioning` normally requires coordinating three separate registration calls — `AddApiVersioning`, `AddMvc`, and `AddApiExplorer` — alongside a version reader that copes with the broad range of `Accept` header values real browsers and HTTP clients send. This namespace provides a single-call registration path and a filtered media-type version reader that handles that coordination automatically.

Start with `AddRestfulApiVersioning` on `IServiceCollection`: it wires all three calls together, installs `RestfulApiVersionReader` to parse the API version from the `Accept` header while ignoring irrelevant browser MIME types, and registers problem-details integration compatible with RFC 7807. To translate Asp.Versioning status-code responses into typed `HttpStatusCodeException` values that the rest of your pipeline can handle, call `UseRestfulApiVersioning` on `IApplicationBuilder` in the middleware pipeline. Configure behaviour — default API version, parameter name, accepted media types, version selector strategy, and problem-details style — through `RestfulApiVersioningOptions`.

Use `SemanticApiVersion`, `SemanticApiVersionParser`, and the semantic version attributes when you need `Asp.Versioning` endpoints to advertise or map versions such as `1.2.3-alpha+build.5`. `SemanticApiVersion.CompareTo` follows Semantic Versioning precedence and ignores build metadata for ordering, while equality keeps build metadata as part of exact version identity. The existing RESTful API Explorer group-name format is unchanged; `SemanticApiVersionFormatter` collapses semantic endpoint versions such as `1.0.1`, `1.2.0`, and `1.2.3-alpha+build.5` to the `v1` group.
Start with `AddRestfulApiVersioning` on `IServiceCollection`: it wires all three calls together, installs `RestfulApiVersionReader` to parse the API version from the `Accept` header while ignoring irrelevant browser MIME types, and registers problem-details integration compatible with RFC 7807. To translate Asp.Versioning status-code responses into typed `HttpStatusCodeException` values that the rest of your pipeline can handle, call `UseRestfulApiVersioning` on `IApplicationBuilder` in the middleware pipeline. Configure behaviour — default API version, parameter name, accepted media types, version selector strategy, and problem-details style — through `RestfulApiVersioningOptions`.
Use `SemanticApiVersion`, `SemanticApiVersionParser`, and the semantic version attributes when you need `Asp.Versioning` endpoints to advertise or map versions such as `1.2.3-alpha+build.5`. `SemanticApiVersion.CompareTo` follows Semantic Versioning precedence and ignores build metadata for ordering, while equality keeps build metadata as part of exact version identity. The existing RESTful API Explorer group-name format is unchanged; `SemanticApiVersionFormatter` collapses semantic endpoint versions such as `1.0.1`, `1.2.0`, and `1.2.3-alpha+build.5` to the `v1` group.

[!INCLUDE [availability-modern](../../includes/availability-modern.md)]

Expand Down
6 changes: 3 additions & 3 deletions .docfx/docfx.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
"*.md"
],
"exclude": [
"bin/**",
"obj/**",
"api/namespaces/**",
"api/types/**"
"api/types/**",
"bin/**",
"obj/**"
]
}
],
Expand Down
4 changes: 2 additions & 2 deletions .docfx/toc.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
- name: Asp.Versioning API
href: api/Codebelt.Extensions.Asp.Versioning.html
href: api/Codebelt.Extensions.Asp.Versioning.yml
- name: NuGet
href: packages
href: packages/index.md
1 change: 1 addition & 0 deletions Codebelt.Extensions.Asp.Versioning.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
</Folder>
<Folder Name="/test/">
<Project Path="test/Codebelt.Extensions.Asp.Versioning.Tests/Codebelt.Extensions.Asp.Versioning.Tests.csproj" />
<Project Path="test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj" />
</Folder>
</Solution>
36 changes: 29 additions & 7 deletions src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,20 @@
public string BuildMetadata { get; }

/// <inheritdoc />
public override int GetHashCode()

Check warning on line 115 in src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor 'GetHashCode' to not reference mutable fields.

Check warning on line 115 in src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor 'GetHashCode' to not reference mutable fields.

Check warning on line 115 in src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor 'GetHashCode' to not reference mutable fields.

Check warning on line 115 in src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor 'GetHashCode' to not reference mutable fields.
{
if (_hashCodeComputed)
{
return _hashCode;
}

if (IsCompatibleWithStandardApiVersion())
{
_hashCode = base.GetHashCode();
_hashCodeComputed = true;
return _hashCode;
}

HashCode hash = default;
hash.Add(MajorVersion.GetValueOrDefault());
hash.Add(MinorVersion.GetValueOrDefault());
Expand All @@ -134,18 +141,26 @@
/// <inheritdoc />
public override bool Equals(object obj)
{
return obj is SemanticApiVersion version && Equals(version);
return obj is ApiVersion version && Equals(version);
}

/// <inheritdoc />
public override bool Equals(ApiVersion other)
{
return other is SemanticApiVersion version
&& MajorVersion == version.MajorVersion
&& MinorVersion == version.MinorVersion
&& PatchVersion == version.PatchVersion
&& string.Equals(Prerelease, version.Prerelease, StringComparison.Ordinal)
&& string.Equals(BuildMetadata, version.BuildMetadata, StringComparison.Ordinal);
if (other is SemanticApiVersion version)
{
return MajorVersion == version.MajorVersion
&& MinorVersion == version.MinorVersion
&& PatchVersion == version.PatchVersion
&& string.Equals(Prerelease, version.Prerelease, StringComparison.Ordinal)
&& string.Equals(BuildMetadata, version.BuildMetadata, StringComparison.Ordinal);
}

return other is not null
&& IsCompatibleWithStandardApiVersion()
&& MajorVersion == other.MajorVersion
&& MinorVersion == other.MinorVersion
&& string.IsNullOrEmpty(other.Status);
}

/// <inheritdoc />
Expand Down Expand Up @@ -293,6 +308,13 @@
return true;
}

private bool IsCompatibleWithStandardApiVersion()
{
return PatchVersion == 0
&& string.IsNullOrEmpty(Prerelease)
&& string.IsNullOrEmpty(BuildMetadata);
}

private static int ValidateNonNegative(int value, string paramName)
{
if (value < 0)
Expand Down
15 changes: 13 additions & 2 deletions src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,24 @@ private static bool TryReadCore(ReadOnlySpan<char> text, out int major, out int
patch = default;

var firstDot = text.IndexOf('.');
if (firstDot <= 0)
if (firstDot < 0)
{
return TryParseNumericIdentifier(text, out major);
}

if (firstDot == 0)
{
return false;
}

var secondDot = text[(firstDot + 1)..].IndexOf('.');
if (secondDot <= 0)
if (secondDot < 0)
{
return TryParseNumericIdentifier(text[..firstDot], out major)
&& TryParseNumericIdentifier(text[(firstDot + 1)..], out minor);
}

if (secondDot == 0)
{
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<RootNamespace>Codebelt.Extensions.Asp.Versioning</RootNamespace>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Codebelt.Extensions.Asp.Versioning\Codebelt.Extensions.Asp.Versioning.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Asp.Versioning;
using Codebelt.Extensions.Xunit.Hosting.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;

namespace Codebelt.Extensions.Asp.Versioning;

public class SemanticApiVersionCompatibility : MinimalWebHostTest<ManagedWebMinimalHostFixture>
{
private const string RequestedVersionHeaderName = "X-Requested-Version";
private static readonly ApiVersion DefaultVersion = new(1, 0);
private static readonly ApiVersion AlternateVersion = new(2, 0);

public SemanticApiVersionCompatibility(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionCompatibility))
{
}

public static TheoryData<string?, ApiVersion> SupportedVersionFormats => new()
{
{ null, AlternateVersion },
{ "1", DefaultVersion },
{ "1.0", DefaultVersion },
{ "1.0.0", DefaultVersion },
{ "2", AlternateVersion },
{ "2.0", AlternateVersion },
{ "2.0.0", AlternateVersion }
};

protected override void ConfigureHost(IHostApplicationBuilder hb)
{
var services = hb.Services;
services.AddRouting();
services.AddRestfulApiVersioning(options =>
{
options.DefaultApiVersion = DefaultVersion;
options.UseBuiltInRfc7807 = true;
});
services.AddSingleton<IApiVersionParser>(SemanticApiVersionParser.Default);
}

public override void ConfigureApplication(IApplicationBuilder app)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
var versionSet = endpoints.NewApiVersionSet()
.HasApiVersion(DefaultVersion)
.HasApiVersion(AlternateVersion)
.Build();

endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, DefaultVersion))
.WithApiVersionSet(versionSet)
.MapToApiVersion(DefaultVersion);

endpoints.MapPost("/requests", (HttpContext context) => WriteResponse(context, AlternateVersion))
.WithApiVersionSet(versionSet)
.MapToApiVersion(AlternateVersion);
});
}

[Theory]
[MemberData(nameof(SupportedVersionFormats))]
public async Task PostRequest_ShouldRouteWhenVersionIsMissingOrUsesShortSemanticFormat(string? version, ApiVersion expectedVersion)
{
using var client = Host.GetTestClient();
using var response = await client.SendAsync(CreateRequest(version));

var payload = await response.Content.ReadAsStringAsync();

TestOutput.WriteLine(version is null ? "v=<unspecified>" : $"v={version}");
TestOutput.WriteLine($"{(int)response.StatusCode} {response.StatusCode}");
if (!string.IsNullOrWhiteSpace(payload))
{
TestOutput.WriteLine(payload);
}

Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(expectedVersion.ToString(), GetHeader(response.Headers, RequestedVersionHeaderName));
}

private static IResult WriteResponse(HttpContext context, ApiVersion version)
{
context.Response.Headers[RequestedVersionHeaderName] = version.ToString();
return Results.NoContent();
}

private static HttpRequestMessage CreateRequest(string? version)
{
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.Content.Headers.ContentType?.Parameters.Add(new NameValueHeaderValue("v", version));
}

return request;
}

private static string GetHeader(HttpResponseHeaders headers, string headerName)
{
return headers.TryGetValues(headerName, out IEnumerable<string>? values)
? Assert.Single(values)
: throw new InvalidOperationException($"Expected response header '{headerName}' was not found.");
}
}
Loading
Loading