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/ApiVersionAliasParser.cs b/src/Codebelt.Extensions.Asp.Versioning/ApiVersionAliasParser.cs new file mode 100644 index 0000000..e2412ce --- /dev/null +++ b/src/Codebelt.Extensions.Asp.Versioning/ApiVersionAliasParser.cs @@ -0,0 +1,134 @@ +using Asp.Versioning; +using Cuemon; +using System; +using System.Collections.Generic; + +namespace Codebelt.Extensions.Asp.Versioning; + +/// +/// Resolves friendly API version aliases before delegating to another . +/// +/// +/// Use this parser when callers should be able to request a version by a shortened or compatibility-oriented token, while the application still works with the canonical instance. +/// Alias matching is performed before the fallback parser is invoked. +/// +/// +public class ApiVersionAliasParser : IApiVersionParser +{ + private readonly IReadOnlyDictionary _aliases; + private readonly IApiVersionParser _fallback; + + /// + /// Creates an API version parser that recognizes shortened aliases for a single semantic API version. + /// + /// The semantic API version to expose through major, major-minor, and major-minor-patch aliases. + /// + /// An that maps aliases such as 1, 1.2, and 1.2.3 to the specified . + /// + /// + /// The returned parser falls back to when a requested version is not one of the generated aliases. + /// + public static IApiVersionParser CreateSemanticVersionAlias(SemanticApiVersion version) + { + return CreateSemanticVersionAlias([version]); + } + + /// + /// Creates an API version parser that recognizes shortened aliases for the specified semantic API versions. + /// + /// The semantic API versions to expose through major, major-minor, and major-minor-patch aliases. + /// + /// An that maps each generated alias to its corresponding . + /// + /// + /// For each supplied version, aliases are generated from , , and . + /// If duplicate aliases are produced, the first version that contributed the alias is retained. The returned parser falls back to when a requested version is not one of the generated aliases. + /// + public static IApiVersionParser CreateSemanticVersionAlias(IEnumerable versions) + { + var aliases = new Dictionary(); + foreach (var version in versions) + { + aliases.TryAdd($"{version.MajorVersion}", version); + aliases.TryAdd($"{version.MajorVersion}.{version.MinorVersion}", version); + aliases.TryAdd($"{version.MajorVersion}.{version.MinorVersion}.{version.PatchVersion}", version); + } + return new ApiVersionAliasParser(aliases); + } + + /// + /// Initializes a new instance of the class that falls back to . + /// + /// The alias map to use before invoking the default parser. + /// + /// The dictionary key is the external version token accepted from a request, and the dictionary value is the canonical returned for that token. + /// + /// + /// is null. + /// + /// + /// contains no entries. + /// + public ApiVersionAliasParser(IReadOnlyDictionary aliases) : this(aliases, ApiVersionParser.Default) + { + } + + /// + /// Initializes a new instance of the class with the specified alias map and fallback parser. + /// + /// The alias map to use before invoking . + /// The parser to use when does not contain the requested version token. + /// + /// The dictionary key is the external version token accepted from a request, and the dictionary value is the canonical returned for that token. + /// Alias lookup uses the comparer configured by the supplied dictionary. + /// + /// + /// or is null. + /// + /// + /// contains no entries. + /// + public ApiVersionAliasParser(IReadOnlyDictionary aliases, IApiVersionParser fallback) + { + Validator.ThrowIfSequenceNullOrEmpty(aliases); + Validator.ThrowIfNull(fallback); + _aliases = aliases; + _fallback = fallback; + } + + /// + /// Parses the specified text into an API version by resolving aliases before using the fallback parser. + /// + /// The API version text to parse. + /// The API version that matched either an alias or the fallback parser. + /// + /// is neither a known alias nor a version accepted by the fallback parser. + /// + public ApiVersion Parse(ReadOnlySpan text) + { + return TryParse(text, out var apiVersion) + ? apiVersion + : throw new FormatException("The specified API version is not valid."); + } + + /// + /// Tries to parse the specified text into an API version by resolving aliases before using the fallback parser. + /// + /// The API version text to parse. + /// + /// When this method returns, contains the API version that matched either an alias or the fallback parser; otherwise, contains null. + /// + /// true if matched an alias or was accepted by the fallback parser; otherwise, false. + /// + /// Override this method to customize alias resolution while preserving the same parse contract. + /// + public virtual bool TryParse(ReadOnlySpan text, out ApiVersion apiVersion) + { + if (_aliases.TryGetValue(text.ToString(), out apiVersion)) + { + return true; + } + + return _fallback.TryParse(text, out apiVersion); + } +} diff --git a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionParser.cs b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs similarity index 100% rename from src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionParser.cs rename to src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersionReader.cs diff --git a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs index af3258d..b50ddba 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/RestfulApiVersioningOptions.cs @@ -111,7 +111,7 @@ public RestfulApiVersioningOptions UseApiVersionSelector() where T : class, I public IApiVersionConventionBuilder Conventions { get; set; } /// - /// Gets or sets the default API version applied to services that d o not have explicit versions. + /// Gets or sets the default API version applied to services that do not have explicit versions. /// /// The default API version applied to services that do not have explicit versions. public ApiVersion DefaultApiVersion { get; set; } diff --git a/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs b/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs index a855e62..9ead1c1 100644 --- a/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs +++ b/src/Codebelt.Extensions.Asp.Versioning/ServiceCollectionExtensions.cs @@ -1,64 +1,93 @@ -using System; -using Asp.Versioning; -using Cuemon; -using Microsoft.Extensions.DependencyInjection; -using Cuemon.AspNetCore.Http; - -namespace Codebelt.Extensions.Asp.Versioning -{ - /// - /// Extension methods for the interface. - /// - public static class ServiceCollectionExtensions - { - /// - /// Adds a compound service API versioning to the specified collection that is optimized for RESTful APIs. - /// - /// The to extend. - /// The that may be configured. - /// A reference to so that additional calls can be chained. - /// This is a convenient method to add API versioning to your ASP.NET Core WebApi. Call AddApiVersioning, AddMvc and AddApiExplorer. Configuration, which is optimized for RESTful APIs, are done through . - public static IServiceCollection AddRestfulApiVersioning(this IServiceCollection services, Action setup = null) - { - Validator.ThrowIfNull(services); - Validator.ThrowIfInvalidConfigurator(setup, out var options); - - services.AddApiVersioning(o => - { - o.DefaultApiVersion = options.DefaultApiVersion; - o.ReportApiVersions = options.ReportApiVersions; - o.AssumeDefaultVersionWhenUnspecified = true; - o.ApiVersionReader = new RestfulApiVersionReader(options.ValidAcceptHeaders, options.ParameterName); - o.ApiVersionSelector = (Activator.CreateInstance(options.ApiVersionSelectorType, o) as IApiVersionSelector)!; - }).AddMvc(o => - { - o.Conventions = options.Conventions; - }).AddApiExplorer(o => - { - o.GroupNameFormat = $"'{options.ParameterName}'VVV"; - o.DefaultApiVersion = options.DefaultApiVersion; - o.SubstituteApiVersionInUrl = true; - }); - - if (options.UseBuiltInRfc7807) - { - services.AddProblemDetails(); - } - else - { - services.AddProblemDetails(o => o.CustomizeProblemDetails = context => - { - var pd = context.ProblemDetails; - if (HttpStatusCodeException.TryParse(pd.Status ?? 500, pd.Detail ?? pd.Title, null, out var statusCodeEquivalentException)) - { - throw statusCodeEquivalentException; - } - - throw new InternalServerErrorException(pd.Detail); - }); - } - - return services; - } - } -} +using System; +using Asp.Versioning; +using Cuemon; +using Microsoft.Extensions.DependencyInjection; +using Cuemon.AspNetCore.Http; + +namespace Codebelt.Extensions.Asp.Versioning +{ + /// + /// Extension methods for the interface. + /// + public static class ServiceCollectionExtensions + { + /// + /// Adds a compound service API versioning to the specified collection that is optimized for RESTful APIs. + /// + /// The to extend. + /// The that may be configured. + /// A reference to so that additional calls can be chained. + /// + /// This is a convenient method to add API versioning to your ASP.NET Core WebApi. Call AddApiVersioning, AddMvc and AddApiExplorer. Configuration, which is optimized for RESTful APIs, are done through . + /// When is a , semantic aliases are automatically registered only for that default version. Applications that expose additional semantic versions and need aliases such as 2 or 2.0 should call with . + /// + public static IServiceCollection AddRestfulApiVersioning(this IServiceCollection services, Action setup = null) + { + Validator.ThrowIfNull(services); + Validator.ThrowIfInvalidConfigurator(setup, out var options); + + services.AddApiVersioning(o => + { + o.DefaultApiVersion = options.DefaultApiVersion; + o.ReportApiVersions = options.ReportApiVersions; + o.AssumeDefaultVersionWhenUnspecified = true; + o.ApiVersionReader = new RestfulApiVersionReader(options.ValidAcceptHeaders, options.ParameterName); + o.ApiVersionSelector = (Activator.CreateInstance(options.ApiVersionSelectorType, o) as IApiVersionSelector)!; + }).AddMvc(o => + { + o.Conventions = options.Conventions; + }).AddApiExplorer(o => + { + o.GroupNameFormat = $"'{options.ParameterName}'VVV"; + o.DefaultApiVersion = options.DefaultApiVersion; + o.SubstituteApiVersionInUrl = true; + }); + + if (options.UseBuiltInRfc7807) + { + services.AddProblemDetails(); + } + else + { + services.AddProblemDetails(o => o.CustomizeProblemDetails = context => + { + var pd = context.ProblemDetails; + if (HttpStatusCodeException.TryParse(pd.Status ?? 500, pd.Detail ?? pd.Title, null, out var statusCodeEquivalentException)) + { + throw statusCodeEquivalentException; + } + + throw new InternalServerErrorException(pd.Detail); + }); + } + + if (options.DefaultApiVersion is SemanticApiVersion semver) + { + services.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias(semver)); + } + + return services; + } + + /// + /// Adds the specified API version parser as the singleton parser used by API versioning services. + /// + /// The concrete type to register. + /// The to extend. + /// The parser instance that should resolve API version values for the application. + /// A reference to so that additional calls can be chained. + /// + /// Register a custom parser when the application accepts version formats that differ from the default parser, such as semantic version aliases or compatibility tokens. + /// The supplied is registered as the singleton. + /// + /// + /// or is null. + /// + public static IServiceCollection AddApiVersionParser(this IServiceCollection services, T parser) where T : IApiVersionParser + { + Validator.ThrowIfNull(services); + Validator.ThrowIfNull(parser); + return services.AddSingleton(parser); + } + } +} 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/SemanticApiVersionCompatibilityTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs new file mode 100644 index 0000000..a45f148 --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionCompatibilityTest.cs @@ -0,0 +1,128 @@ +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 SemanticApiVersionCompatibilityTest : 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 SemanticApiVersionCompatibilityTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionCompatibilityTest)) + { + } + + 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(new ApiVersionAliasParser(new Dictionary(StringComparer.Ordinal) + { + ["1"] = DefaultVersion, + ["1.0"] = DefaultVersion, + ["1.0.0"] = DefaultVersion, + ["2"] = AlternateVersion, + ["2.0"] = AlternateVersion, + ["2.0.0"] = AlternateVersion + }, ApiVersionParser.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/SemanticApiVersionDefaultCompatibilityTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs new file mode 100644 index 0000000..ad839d3 --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionDefaultCompatibilityTest.cs @@ -0,0 +1,121 @@ +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 SemanticApiVersionDefaultCompatibilityTest : MinimalWebHostTest +{ + private const string RequestedVersionHeaderName = "X-Requested-Version"; + private static readonly SemanticApiVersion DefaultVersion = new(1, 0, 0); + private static readonly SemanticApiVersion AlternateVersion = new(2, 0, 0); + + public SemanticApiVersionDefaultCompatibilityTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionDefaultCompatibilityTest)) + { + } + + 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.AddApiVersionParser(ApiVersionAliasParser.CreateSemanticVersionAlias([DefaultVersion, AlternateVersion])); + } + + 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/SemanticApiVersionNonProductionTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProductionTest.cs new file mode 100644 index 0000000..db3b07b --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionNonProductionTest.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 SemanticApiVersionNonProductionTest : 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 SemanticApiVersionNonProductionTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionNonProductionTest)) + { + } + + 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/SemanticApiVersionProductionTest.cs b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProductionTest.cs new file mode 100644 index 0000000..c271649 --- /dev/null +++ b/test/Codebelt.Extensions.Asp.Versioning.FunctionalTests/SemanticApiVersionProductionTest.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 SemanticApiVersionProductionTest : 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 SemanticApiVersionProductionTest(ManagedWebMinimalHostFixture hostFixture, ITestOutputHelper output) : base(hostFixture, output, typeof(SemanticApiVersionProductionTest)) + { + } + + 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..ff1430a 100644 --- a/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs +++ b/test/Codebelt.Extensions.Asp.Versioning.Tests/SemanticApiVersionTest.cs @@ -59,6 +59,26 @@ public void Equals_ShouldReturnExpectedResult_WhenComparingDifferentObjectTypes( Assert.False(sut.Equals(null)); } + [Fact] + public void Equals_ShouldReturnFalse_WhenComparingPatchZeroReleaseSemanticVersionToApiVersion() + { + var semantic = new SemanticApiVersion(1, 0, 0); + var standard = new ApiVersion(1, 0); + + Assert.False(semantic.Equals(standard)); + Assert.False(standard.Equals(semantic)); + } + + [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() {