diff --git a/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md b/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md
index d502b11..526d370 100644
--- a/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md
+++ b/.docfx/api/namespaces/Codebelt.Extensions.Asp.Versioning.md
@@ -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)]
diff --git a/.docfx/docfx.json b/.docfx/docfx.json
index ed3a818..cc13574 100644
--- a/.docfx/docfx.json
+++ b/.docfx/docfx.json
@@ -30,10 +30,10 @@
"*.md"
],
"exclude": [
- "bin/**",
- "obj/**",
"api/namespaces/**",
- "api/types/**"
+ "api/types/**",
+ "bin/**",
+ "obj/**"
]
}
],
diff --git a/.docfx/toc.yml b/.docfx/toc.yml
index 9eeba78..61522bb 100644
--- a/.docfx/toc.yml
+++ b/.docfx/toc.yml
@@ -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
diff --git a/Codebelt.Extensions.Asp.Versioning.slnx b/Codebelt.Extensions.Asp.Versioning.slnx
index 791eb6b..38bb9fa 100644
--- a/Codebelt.Extensions.Asp.Versioning.slnx
+++ b/Codebelt.Extensions.Asp.Versioning.slnx
@@ -4,5 +4,6 @@
+
diff --git a/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs b/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs
index a4184a7..5e6112e 100644
--- a/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs
+++ b/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersion.cs
@@ -119,6 +119,13 @@ public override int GetHashCode()
return _hashCode;
}
+ if (IsCompatibleWithStandardApiVersion())
+ {
+ _hashCode = base.GetHashCode();
+ _hashCodeComputed = true;
+ return _hashCode;
+ }
+
HashCode hash = default;
hash.Add(MajorVersion.GetValueOrDefault());
hash.Add(MinorVersion.GetValueOrDefault());
@@ -134,18 +141,26 @@ public override int GetHashCode()
///
public override bool Equals(object obj)
{
- return obj is SemanticApiVersion version && Equals(version);
+ return obj is ApiVersion version && Equals(version);
}
///
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);
}
///
@@ -293,6 +308,13 @@ private static bool IsNumericIdentifier(string value)
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)
diff --git a/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersionParser.cs b/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersionParser.cs
index 947049c..13867ed 100644
--- a/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersionParser.cs
+++ b/src/Codebelt.Extensions.Asp.Versioning/SemanticApiVersionParser.cs
@@ -93,13 +93,24 @@ private static bool TryReadCore(ReadOnlySpan 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;
}
diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj
new file mode 100644
index 0000000..fe3b47e
--- /dev/null
+++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/Codebelt.Extensions.Asp.Versioning.FunctionalTests.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Codebelt.Extensions.Asp.Versioning
+ enable
+
+
+
+
+
+
+
diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibility.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibility.cs
new file mode 100644
index 0000000..a94b5fa
--- /dev/null
+++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibility.cs
@@ -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
+{
+ 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 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(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=" : $"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? values)
+ ? Assert.Single(values)
+ : throw new InvalidOperationException($"Expected response header '{headerName}' was not found.");
+ }
+}
diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProduction.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProduction.cs
new file mode 100644
index 0000000..c843f0f
--- /dev/null
+++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProduction.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+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 SemanticApiVersionNonProduction : MinimalWebHostTest
+{
+ private const string SemanticVersionHeaderName = "X-Semantic-Version";
+ private const string HttpMethodHeaderName = "X-Http-Method";
+ private static readonly SemanticApiVersion DefaultVersion = new(1, 2, 3, "alpha", "build.5");
+ private static readonly SemanticApiVersion AlternateVersion = new(1, 2, 3, buildMetadata: "build.7");
+ private static readonly string[] RoutedMethods =
+ [
+ HttpMethods.Get,
+ HttpMethods.Head,
+ HttpMethods.Options,
+ HttpMethods.Post,
+ HttpMethods.Put,
+ HttpMethods.Patch,
+ HttpMethods.Delete
+ ];
+
+ public SemanticApiVersionNonProduction(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionProduction))
+ {
+ }
+
+ public static TheoryData VersionedHttpMethods => new()
+ {
+ { HttpMethods.Get, HttpStatusCode.NotAcceptable, false },
+ { HttpMethods.Head, HttpStatusCode.NotAcceptable, false },
+ { HttpMethods.Options, HttpStatusCode.NotAcceptable, false },
+ { HttpMethods.Post, HttpStatusCode.UnsupportedMediaType, true },
+ { HttpMethods.Put, HttpStatusCode.UnsupportedMediaType, true },
+ { HttpMethods.Patch, HttpStatusCode.UnsupportedMediaType, true },
+ { HttpMethods.Delete, HttpStatusCode.NotAcceptable, false }
+ };
+
+ protected override void ConfigureHost(IHostApplicationBuilder hb)
+ {
+ var services = hb.Services;
+ services.AddRouting();
+ services.AddRestfulApiVersioning(options =>
+ {
+ options.DefaultApiVersion = DefaultVersion;
+ options.UseBuiltInRfc7807 = true;
+ });
+ services.AddSingleton(SemanticApiVersionParser.Default);
+ }
+
+ public override void ConfigureApplication(IApplicationBuilder app)
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ var versionSet = endpoints.NewApiVersionSet()
+ .HasApiVersion(DefaultVersion)
+ .HasApiVersion(AlternateVersion)
+ .Build();
+
+ foreach (var method in RoutedMethods)
+ {
+ var route = GetRoute(method);
+ endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, DefaultVersion))
+ .WithApiVersionSet(versionSet)
+ .MapToApiVersion(DefaultVersion);
+
+ endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, AlternateVersion))
+ .WithApiVersionSet(versionSet)
+ .MapToApiVersion(AlternateVersion);
+ }
+ });
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task ExplicitSemanticVersion_ShouldRouteToDefaultSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody)
+ {
+ _ = unsupportedStatusCode;
+
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, DefaultVersion.ToString(), includeRequestBody));
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}")));
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName));
+ Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName));
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task ExplicitSemanticVersion_ShouldRouteToReleaseSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody)
+ {
+ _ = unsupportedStatusCode;
+
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, AlternateVersion.ToString(), includeRequestBody));
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}")));
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName));
+ Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName));
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task UnspecifiedSemanticVersion_ShouldUseCurrentImplementationSelectorAcrossHttpMethods(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody)
+ {
+ _ = unsupportedStatusCode;
+
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, version: null, includeRequestBody));
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}")));
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName));
+ Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName));
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task UnknownSemanticVersion_ShouldReturnExpectedNegotiationStatusAcrossHttpMethods(string method, HttpStatusCode expectedStatusCode, bool includeRequestBody)
+ {
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, "1.2.4-alpha+build.9", includeRequestBody));
+
+ var payload = await response.Content.ReadAsStringAsync();
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(payload);
+
+ Assert.Equal(expectedStatusCode, response.StatusCode);
+ Assert.False(response.Headers.Contains(SemanticVersionHeaderName));
+ }
+
+ private static IResult WriteResponse(HttpContext context, string method, SemanticApiVersion version)
+ {
+ context.Response.Headers[SemanticVersionHeaderName] = version.ToString();
+ context.Response.Headers[HttpMethodHeaderName] = method;
+ return Results.NoContent();
+ }
+
+ private static HttpRequestMessage CreateRequest(string method, string? version, bool includeRequestBody)
+ {
+ var request = new HttpRequestMessage(new HttpMethod(method), GetRoute(method));
+ request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}");
+ if (includeRequestBody)
+ {
+ request.Content = new StringContent("""{"payload":"semantic"}""", Encoding.UTF8, "application/json");
+ }
+
+ return request;
+ }
+
+ private static string GetHeader(HttpResponseHeaders headers, string headerName)
+ {
+ return headers.TryGetValues(headerName, out IEnumerable? values)
+ ? Assert.Single(values)
+ : throw new InvalidOperationException($"Expected response header '{headerName}' was not found.");
+ }
+
+ private static string GetRoute(string method)
+ {
+ return $"/semantic/{method.ToLowerInvariant()}";
+ }
+}
diff --git a/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProduction.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProduction.cs
new file mode 100644
index 0000000..62fd1e6
--- /dev/null
+++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProduction.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+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 SemanticApiVersionProduction : MinimalWebHostTest
+{
+ private const string SemanticVersionHeaderName = "X-Semantic-Version";
+ private const string HttpMethodHeaderName = "X-Http-Method";
+ private static readonly SemanticApiVersion DefaultVersion = new(1, 2, 3);
+ private static readonly SemanticApiVersion AlternateVersion = new(2, 0, 0);
+ private static readonly string[] RoutedMethods =
+ [
+ HttpMethods.Get,
+ HttpMethods.Head,
+ HttpMethods.Options,
+ HttpMethods.Post,
+ HttpMethods.Put,
+ HttpMethods.Patch,
+ HttpMethods.Delete
+ ];
+
+ public SemanticApiVersionProduction(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionProduction))
+ {
+ }
+
+ public static TheoryData VersionedHttpMethods => new()
+ {
+ { HttpMethods.Get, HttpStatusCode.NotAcceptable, false },
+ { HttpMethods.Head, HttpStatusCode.NotAcceptable, false },
+ { HttpMethods.Options, HttpStatusCode.NotAcceptable, false },
+ { HttpMethods.Post, HttpStatusCode.UnsupportedMediaType, true },
+ { HttpMethods.Put, HttpStatusCode.UnsupportedMediaType, true },
+ { HttpMethods.Patch, HttpStatusCode.UnsupportedMediaType, true },
+ { HttpMethods.Delete, HttpStatusCode.NotAcceptable, false }
+ };
+
+ protected override void ConfigureHost(IHostApplicationBuilder hb)
+ {
+ var services = hb.Services;
+ services.AddRouting();
+ services.AddRestfulApiVersioning(options =>
+ {
+ options.DefaultApiVersion = DefaultVersion;
+ options.UseBuiltInRfc7807 = true;
+ });
+ services.AddSingleton(SemanticApiVersionParser.Default);
+ }
+
+ public override void ConfigureApplication(IApplicationBuilder app)
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ var versionSet = endpoints.NewApiVersionSet()
+ .HasApiVersion(DefaultVersion)
+ .HasApiVersion(AlternateVersion)
+ .Build();
+
+ foreach (var method in RoutedMethods)
+ {
+ var route = GetRoute(method);
+ endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, DefaultVersion))
+ .WithApiVersionSet(versionSet)
+ .MapToApiVersion(DefaultVersion);
+
+ endpoints.MapMethods(route, [method], (HttpContext context) => WriteResponse(context, method, AlternateVersion))
+ .WithApiVersionSet(versionSet)
+ .MapToApiVersion(AlternateVersion);
+ }
+ });
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task ExplicitSemanticVersion_ShouldRouteToDefaultSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody)
+ {
+ _ = unsupportedStatusCode;
+
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, DefaultVersion.ToString(), includeRequestBody));
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}")));
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(DefaultVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName));
+ Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName));
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task ExplicitSemanticVersion_ShouldRouteToReleaseSemanticEndpoint(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody)
+ {
+ _ = unsupportedStatusCode;
+
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, AlternateVersion.ToString(), includeRequestBody));
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}")));
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName));
+ Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName));
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task UnspecifiedSemanticVersion_ShouldUseCurrentImplementationSelectorAcrossHttpMethods(string method, HttpStatusCode unsupportedStatusCode, bool includeRequestBody)
+ {
+ _ = unsupportedStatusCode;
+
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, version: null, includeRequestBody));
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(string.Join(", ", response.Headers.Select(header => $"{header.Key}={string.Join("|", header.Value)}")));
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(AlternateVersion.ToString(), GetHeader(response.Headers, SemanticVersionHeaderName));
+ Assert.Equal(method, GetHeader(response.Headers, HttpMethodHeaderName));
+ }
+
+ [Theory]
+ [MemberData(nameof(VersionedHttpMethods))]
+ public async Task UnknownSemanticVersion_ShouldReturnExpectedNegotiationStatusAcrossHttpMethods(string method, HttpStatusCode expectedStatusCode, bool includeRequestBody)
+ {
+ using var client = Host.GetTestClient();
+ using var response = await client.SendAsync(CreateRequest(method, "1.2.4", includeRequestBody));
+
+ var payload = await response.Content.ReadAsStringAsync();
+
+ TestOutput.WriteLine($"{method} {GetRoute(method)} => {(int)response.StatusCode}");
+ TestOutput.WriteLine(payload);
+
+ Assert.Equal(expectedStatusCode, response.StatusCode);
+ Assert.False(response.Headers.Contains(SemanticVersionHeaderName));
+ }
+
+ private static IResult WriteResponse(HttpContext context, string method, SemanticApiVersion version)
+ {
+ context.Response.Headers[SemanticVersionHeaderName] = version.ToString();
+ context.Response.Headers[HttpMethodHeaderName] = method;
+ return Results.NoContent();
+ }
+
+ private static HttpRequestMessage CreateRequest(string method, string? version, bool includeRequestBody)
+ {
+ var request = new HttpRequestMessage(new HttpMethod(method), GetRoute(method));
+ request.Headers.Add("Accept", version is null ? "application/json" : $"application/json;v={version}");
+ if (includeRequestBody)
+ {
+ request.Content = new StringContent("""{"payload":"semantic"}""", Encoding.UTF8, "application/json");
+ }
+
+ return request;
+ }
+
+ private static string GetHeader(HttpResponseHeaders headers, string headerName)
+ {
+ return headers.TryGetValues(headerName, out IEnumerable? values)
+ ? Assert.Single(values)
+ : throw new InvalidOperationException($"Expected response header '{headerName}' was not found.");
+ }
+
+ private static string GetRoute(string method)
+ {
+ return $"/semantic/{method.ToLowerInvariant()}";
+ }
+}
diff --git a/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs b/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs
index 8eebd16..5b571a5 100644
--- a/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs
+++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs
@@ -59,6 +59,27 @@ public void Equals_ShouldReturnExpectedResult_WhenComparingDifferentObjectTypes(
Assert.False(sut.Equals(null));
}
+ [Fact]
+ public void Equals_ShouldTreatPatchZeroReleaseSemanticVersionAsEquivalentToApiVersion()
+ {
+ var semantic = new SemanticApiVersion(1, 0, 0);
+ var standard = new ApiVersion(1, 0);
+
+ Assert.True(semantic.Equals(standard));
+ Assert.True(standard.Equals(semantic));
+ Assert.Equal(standard.GetHashCode(), semantic.GetHashCode());
+ }
+
+ [Fact]
+ public void Equals_ShouldReturnFalse_WhenComparingNonZeroPatchSemanticVersionToApiVersion()
+ {
+ var semantic = new SemanticApiVersion(1, 2, 3);
+ var standard = new ApiVersion(1, 2);
+
+ Assert.False(semantic.Equals(standard));
+ Assert.False(standard.Equals(semantic));
+ }
+
[Fact]
public void GetHashCode_ShouldReturnCachedHashCode_WhenCalledMoreThanOnce()
{
@@ -133,6 +154,8 @@ public void CompareTo_ShouldApplySemanticVersionPrereleasePrecedence(string lowe
}
[Theory]
+ [InlineData("1", 1, 0, 0, null, null)]
+ [InlineData("1.2", 1, 2, 0, null, null)]
[InlineData("1.2.3", 1, 2, 3, null, null)]
[InlineData("1.2.3-alpha.1", 1, 2, 3, "alpha.1", null)]
[InlineData("1.2.3+build.01", 1, 2, 3, null, "build.01")]
@@ -152,8 +175,6 @@ public void Parse_ShouldReturnSemanticApiVersion_WhenVersionIsValid(string versi
[Theory]
[InlineData("")]
- [InlineData("1")]
- [InlineData("1.2")]
[InlineData("1..3")]
[InlineData("1.2.")]
[InlineData("1.2.a")]
@@ -183,7 +204,7 @@ public void TryParse_ShouldReturnFalse_WhenVersionIsInvalid(string version)
[Fact]
public void Parse_ShouldThrowFormatException_WhenVersionIsInvalid()
{
- var sut = Assert.Throws(() => SemanticApiVersionParser.Default.Parse("1.2"));
+ var sut = Assert.Throws(() => SemanticApiVersionParser.Default.Parse("1..2"));
Assert.Equal("The specified API version is not a valid semantic version.", sut.Message);
}