From 82b39b897cfcf4b2b17473216f64eb03a62b4e1c Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 19 Oct 2026 14:53:56 +0100 Subject: [PATCH 1/2] Add API defaults library --- .github/workflows/createrelease.yml | 3 + .github/workflows/pullrequest.yml | 3 + Directory.Packages.props | 6 + README.md | 33 +++ Shared.sln | 20 +- .../Program.cs | 29 +++ ...actionProcessing.ApiDefaults.Sample.csproj | 14 ++ .../ApiDefaultsIntegrationTests.cs | 193 ++++++++++++++++++ ...sactionProcessing.ApiDefaults.Tests.csproj | 27 +++ .../ApiDefaultsExtensions.cs | 81 ++++++++ .../ApiDefaultsOptions.cs | 13 ++ .../ApiVersions.cs | 19 ++ TransactionProcessing.ApiDefaults/README.md | 52 +++++ .../TransactionProcessing.ApiDefaults.csproj | 25 +++ ...nFallbackEndpointRouteBuilderExtensions.cs | 173 ++++++++++++++++ 15 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 TransactionProcessing.ApiDefaults.Sample/Program.cs create mode 100644 TransactionProcessing.ApiDefaults.Sample/TransactionProcessing.ApiDefaults.Sample.csproj create mode 100644 TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs create mode 100644 TransactionProcessing.ApiDefaults.Tests/TransactionProcessing.ApiDefaults.Tests.csproj create mode 100644 TransactionProcessing.ApiDefaults/ApiDefaultsExtensions.cs create mode 100644 TransactionProcessing.ApiDefaults/ApiDefaultsOptions.cs create mode 100644 TransactionProcessing.ApiDefaults/ApiVersions.cs create mode 100644 TransactionProcessing.ApiDefaults/README.md create mode 100644 TransactionProcessing.ApiDefaults/TransactionProcessing.ApiDefaults.csproj create mode 100644 TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs diff --git a/.github/workflows/createrelease.yml b/.github/workflows/createrelease.yml index dfe1f84..44097d8 100644 --- a/.github/workflows/createrelease.yml +++ b/.github/workflows/createrelease.yml @@ -57,6 +57,9 @@ jobs: dotnet pack "Shared.Results.Web\Shared.Results.Web.csproj" /p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output Nugets dotnet nuget push Nugets/Shared.Results.Web.${{ steps.get_version.outputs.VERSION }}.nupkg --api-key ${{ secrets.PRIVATEFEED_APIKEY }} --source ${{ secrets.PRIVATEFEED_URL }} + dotnet pack "TransactionProcessing.ApiDefaults\TransactionProcessing.ApiDefaults.csproj" /p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output Nugets + dotnet nuget push Nugets/TransactionProcessing.ApiDefaults.${{ steps.get_version.outputs.VERSION }}.nupkg --api-key ${{ secrets.PRIVATEFEED_APIKEY }} --source ${{ secrets.PRIVATEFEED_URL }} + create-shared-issue: needs: build uses: TransactionProcessing/org-ci-workflows/.github/workflows/reusable-create-shared-issue.yml@main diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 8afcd34..117e1a9 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -32,6 +32,9 @@ jobs: echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" dotnet test "Shared.Tests/Shared.Tests.csproj" + - name: Run API Defaults Tests + run: dotnet test "TransactionProcessing.ApiDefaults.Tests/TransactionProcessing.ApiDefaults.Tests.csproj" --configuration Release + - name: Run Event Store Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" diff --git a/Directory.Packages.props b/Directory.Packages.props index 8f90f5a..b2fbbf6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,14 +4,20 @@ + + + + + + diff --git a/README.md b/README.md index e3ffc07..f957838 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Reusable .NET libraries for transaction-processing services, including logging, | `Shared.Logger` | Logging and correlation context | [![Version](https://img.shields.io/feedz/v/transactionprocessing/nugets/Shared.Logger.svg?label=Version)](https://feedz.io/portal/org/transactionprocessing/repository/nugets/packages/Shared.Logger) | | `Shared.Results` | Result types and retry policies | [![Version](https://img.shields.io/feedz/v/transactionprocessing/nugets/Shared.Results.svg?label=Version)](https://feedz.io/portal/org/transactionprocessing/repository/nugets/packages/Shared.Results) | | `Shared.Results.Web` | ASP.NET result mappings | [![Version](https://img.shields.io/feedz/v/transactionprocessing/nugets/Shared.Results.Web.svg?label=Version)](https://feedz.io/portal/org/transactionprocessing/repository/nugets/packages/Shared.Results.Web) | +| `TransactionProcessing.ApiDefaults` | Minimal API versioning, OpenAPI, and Scalar defaults | [![Version](https://img.shields.io/feedz/v/transactionprocessing/nugets/TransactionProcessing.ApiDefaults.svg?label=Version)](https://feedz.io/portal/org/transactionprocessing/repository/nugets/packages/TransactionProcessing.ApiDefaults) | ## Requirements @@ -45,6 +46,38 @@ Other packages can be installed independently depending on the functionality req - Entity Framework Core helpers - EventStore/KurrentDB integration - Reusable integration-testing infrastructure +- Shared URL-based API versioning, per-version OpenAPI documents, and Scalar UI (`TransactionProcessing.ApiDefaults`) + +## API defaults sample + +`TransactionProcessing.ApiDefaults` is a separate package. It is not referenced by `Shared`, so applications that only consume the non-web shared packages do not gain its ASP.NET Core dependencies. + +Install it into an ASP.NET Core Minimal API project: + +```bash +dotnet add package TransactionProcessing.ApiDefaults +``` + +Register the defaults before building the application, then map versioned endpoint groups and enable the shared OpenAPI and Scalar endpoints: + +```csharp +using Asp.Versioning; + +builder.Services.AddApiDefaults(options => options.Title = "Transaction API"); + +var app = builder.Build(); +app.UseApiDefaults(); + +var api = app.NewVersionedApi("Transactions"); +var transactions = api + .MapGroup("/api/v{version:apiVersion}/transactions") + .HasApiVersion(ApiVersions.V1); +transactions.MapGet("/", GetTransactions).MapToApiVersion(ApiVersions.V1); + +app.Run(); +``` + +The package serves version-specific OpenAPI documents at `/openapi/v1.json` and the Scalar reference at `/scalar`. The version list is discovered from mapped endpoints; adding another version does not require updating the shared package. Run the complete example with `dotnet run --project TransactionProcessing.ApiDefaults.Sample`. ## Testing diff --git a/Shared.sln b/Shared.sln index 5b13b45..cec953f 100644 --- a/Shared.sln +++ b/Shared.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.0.11205.157 @@ -40,6 +40,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Packages.props = Directory.Packages.props EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessing.ApiDefaults", "TransactionProcessing.ApiDefaults\TransactionProcessing.ApiDefaults.csproj", "{95B8412E-6764-4115-BB49-2EC3674AF2B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessing.ApiDefaults.Tests", "TransactionProcessing.ApiDefaults.Tests\TransactionProcessing.ApiDefaults.Tests.csproj", "{BF7D54EF-C5C8-422E-86FB-73A371DA18E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessing.ApiDefaults.Sample", "TransactionProcessing.ApiDefaults.Sample\TransactionProcessing.ApiDefaults.Sample.csproj", "{705E5A37-38A8-4F4B-9594-180B90B1A2BC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,6 +108,18 @@ Global {9A7AF486-C3B6-463D-812E-DD6D5517C7F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {9A7AF486-C3B6-463D-812E-DD6D5517C7F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {9A7AF486-C3B6-463D-812E-DD6D5517C7F3}.Release|Any CPU.Build.0 = Release|Any CPU + {95B8412E-6764-4115-BB49-2EC3674AF2B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95B8412E-6764-4115-BB49-2EC3674AF2B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {95B8412E-6764-4115-BB49-2EC3674AF2B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {95B8412E-6764-4115-BB49-2EC3674AF2B3}.Release|Any CPU.Build.0 = Release|Any CPU + {BF7D54EF-C5C8-422E-86FB-73A371DA18E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF7D54EF-C5C8-422E-86FB-73A371DA18E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF7D54EF-C5C8-422E-86FB-73A371DA18E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF7D54EF-C5C8-422E-86FB-73A371DA18E1}.Release|Any CPU.Build.0 = Release|Any CPU + {705E5A37-38A8-4F4B-9594-180B90B1A2BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {705E5A37-38A8-4F4B-9594-180B90B1A2BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {705E5A37-38A8-4F4B-9594-180B90B1A2BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {705E5A37-38A8-4F4B-9594-180B90B1A2BC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TransactionProcessing.ApiDefaults.Sample/Program.cs b/TransactionProcessing.ApiDefaults.Sample/Program.cs new file mode 100644 index 0000000..0249454 --- /dev/null +++ b/TransactionProcessing.ApiDefaults.Sample/Program.cs @@ -0,0 +1,29 @@ +using Asp.Versioning; +using TransactionProcessing.ApiDefaults; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Services.AddApiDefaults(options => +{ + options.Title = "Transaction API"; + options.Description = "Sample API demonstrating the shared API defaults."; +}); + +WebApplication app = builder.Build(); +app.UseApiDefaults(); + +var api = app.NewVersionedApi("Transactions"); +var transactions = api + .MapGroup("/api/v{version:apiVersion}/transactions") + .HasApiVersion(ApiVersions.V1) + .HasApiVersion(ApiVersions.V2) + .HasApiVersion(ApiVersions.V3) + .HasApiVersion(ApiVersions.V4); + +transactions.MapGetWithVersionFallback("/", new Dictionary> +{ + [ApiVersions.V1] = _ => Results.Ok(new { Message = "V1 transaction response" }), + [ApiVersions.V2] = _ => Results.Ok(new { Message = "V2 transaction response" }) +}); +transactions.MapGet("/shared", () => Results.Ok(new { Message = "Available in all supported versions" })); + +app.Run(); diff --git a/TransactionProcessing.ApiDefaults.Sample/TransactionProcessing.ApiDefaults.Sample.csproj b/TransactionProcessing.ApiDefaults.Sample/TransactionProcessing.ApiDefaults.Sample.csproj new file mode 100644 index 0000000..f624ee2 --- /dev/null +++ b/TransactionProcessing.ApiDefaults.Sample/TransactionProcessing.ApiDefaults.Sample.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + false + + + + + + + diff --git a/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs b/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs new file mode 100644 index 0000000..081e68a --- /dev/null +++ b/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Text.Json; +using Asp.Versioning; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using TransactionProcessing.ApiDefaults; +using Xunit; + +namespace TransactionProcessing.ApiDefaults.Tests; + +public sealed class ApiDefaultsIntegrationTests +{ + [Fact] + public void ApiDefaultsOptions_HasExpectedDefaults() + { + ApiDefaultsOptions options = new(); + + options.Title.ShouldBe("API"); + options.Description.ShouldBeNull(); + } + + [Fact] + public async Task Endpoints_AreRoutedByExplicitUrlVersion() + { + await using var app = await CreateApp(); + using var client = app.GetTestClient(); + + (await client.GetStringAsync("/api/v1/transactions/shared", TestContext.Current.CancellationToken)).ShouldBe("shared"); + (await client.GetStringAsync("/api/v2/transactions/shared", TestContext.Current.CancellationToken)).ShouldBe("shared"); + (await client.GetStringAsync("/api/v1/transactions/v1", TestContext.Current.CancellationToken)).ShouldBe("v1"); + (await client.GetStringAsync("/api/v2/transactions/v2", TestContext.Current.CancellationToken)).ShouldBe("v2"); + } + + [Fact] + public async Task VersionSpecificEndpoints_AreUnavailableForOtherVersions() + { + await using var app = await CreateApp(); + using var client = app.GetTestClient(); + + (await client.GetAsync("/api/v1/transactions/v2", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + (await client.GetAsync("/api/v2/transactions/v1", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task VersionFallback_UsesLatestPriorHandlerUntilOverridden() + { + await using (var app = await CreateApp(includeV3FallbackOverride: false)) + using (var client = app.GetTestClient()) + { + (await client.GetStringAsync("/api/v3/transactions", TestContext.Current.CancellationToken)).ShouldBe("v2"); + (await client.GetStringAsync("/api/v3/transactions/async", TestContext.Current.CancellationToken)).ShouldBe("async-v2"); + (await client.GetAsync("/api/v3/transactions/future-only", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + await using (var app = await CreateApp(includeV3FallbackOverride: true)) + using (var client = app.GetTestClient()) + { + (await client.GetStringAsync("/api/v3/transactions", TestContext.Current.CancellationToken)).ShouldBe("v3"); + } + } + + [Fact] + public async Task UnsupportedAndMissingVersions_AreNotRouted() + { + await using var app = await CreateApp(); + using var client = app.GetTestClient(); + + (await client.GetAsync("/api/v4/transactions/shared", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + (await client.GetAsync("/api/transactions/shared", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Responses_ReportSupportedAndDeprecatedVersions() + { + await using var app = await CreateApp(); + using var client = app.GetTestClient(); + + using var response = await client.GetAsync("/api/v1/transactions/shared", TestContext.Current.CancellationToken); + + response.Headers.GetValues("api-supported-versions").Single().Split(',').Select(version => version.Trim()).ShouldContain("1.0"); + response.Headers.GetValues("api-supported-versions").Single().Split(',').Select(version => version.Trim()).ShouldContain("3.0"); + response.Headers.GetValues("api-deprecated-versions").Single().Split(',').Select(version => version.Trim()).ShouldContain("2.0"); + } + + [Fact] + public async Task OpenApiDocuments_ContainOnlyApplicableEndpointsAndMetadata() + { + await using var app = await CreateApp(); + using var client = app.GetTestClient(); + + using JsonDocument v1 = JsonDocument.Parse(await client.GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken)); + using JsonDocument v2 = JsonDocument.Parse(await client.GetStringAsync("/openapi/v2.json", TestContext.Current.CancellationToken)); + using JsonDocument v3 = JsonDocument.Parse(await client.GetStringAsync("/openapi/v3.json", TestContext.Current.CancellationToken)); + + v1.RootElement.GetProperty("info").GetProperty("title").GetString().ShouldBe("Test Transactions API"); + v1.RootElement.GetProperty("info").GetProperty("description").GetString().ShouldBe("API defaults integration test."); + v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions/shared"); + v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions"); + v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions/v1"); + v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/v2"); + v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/async"); + v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/shared"); + v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/v2"); + v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/async"); + v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v2/transactions/v1"); + v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/shared"); + v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions"); + v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/v3"); + v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/async"); + v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/future-only"); + v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v2/transactions/future-only"); + v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v3/transactions/future-only"); + } + + [Fact] + public async Task Scalar_ListsAllDiscoveredApiVersions() + { + await using var app = await CreateApp(); + using var client = app.GetTestClient(); + + using var rootResponse = await client.GetAsync("/scalar", TestContext.Current.CancellationToken); + using var response = await client.GetAsync("/scalar/", TestContext.Current.CancellationToken); + string html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + rootResponse.StatusCode.ShouldBe(HttpStatusCode.Found); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + html.ShouldContain("\"url\":\"openapi/v1.json\""); + html.ShouldContain("\"url\":\"openapi/v2.json\""); + html.ShouldContain("\"url\":\"openapi/v3.json\""); + } + + private static async Task CreateApp(bool includeV3FallbackOverride = false) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = Environments.Development + }); + builder.WebHost.UseTestServer(); + builder.Services.AddApiDefaults(options => + { + options.Title = "Test Transactions API"; + options.Description = "API defaults integration test."; + }); + + WebApplication app = builder.Build(); + app.UseApiDefaults(); + + var api = app.NewVersionedApi("Transactions"); + var transactions = api + .MapGroup("/api/v{version:apiVersion}/transactions") + .HasApiVersion(ApiVersions.V1) + .HasDeprecatedApiVersion(ApiVersions.V2) + .HasApiVersion(ApiVersions.V3); + + var versionedResponses = new Dictionary> + { + [ApiVersions.V1] = _ => Results.Text("v1"), + [ApiVersions.V2] = _ => Results.Text("v2") + }; + if (includeV3FallbackOverride) + { + versionedResponses[ApiVersions.V3] = _ => Results.Text("v3"); + } + + transactions.MapGetWithVersionFallback("/", versionedResponses); + transactions.MapGetWithVersionFallbackAsync("/async", new Dictionary>> + { + [ApiVersions.V2] = async (_, cancellationToken) => + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + return Results.Text("async-v2"); + } + }); + transactions.MapGetWithVersionFallback("/future-only", new Dictionary> + { + [new ApiVersion(4, 0)] = _ => Results.Text("future"), + }); + + transactions.MapGet("/shared", () => "shared"); + transactions.MapGet("/v1", () => "v1").MapToApiVersion(ApiVersions.V1); + transactions.MapGet("/v2", () => "v2").MapToApiVersion(ApiVersions.V2); + transactions.MapGet("/v3", () => "v3").MapToApiVersion(ApiVersions.V3); + + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } +} diff --git a/TransactionProcessing.ApiDefaults.Tests/TransactionProcessing.ApiDefaults.Tests.csproj b/TransactionProcessing.ApiDefaults.Tests/TransactionProcessing.ApiDefaults.Tests.csproj new file mode 100644 index 0000000..74eb1ed --- /dev/null +++ b/TransactionProcessing.ApiDefaults.Tests/TransactionProcessing.ApiDefaults.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/TransactionProcessing.ApiDefaults/ApiDefaultsExtensions.cs b/TransactionProcessing.ApiDefaults/ApiDefaultsExtensions.cs new file mode 100644 index 0000000..b52837e --- /dev/null +++ b/TransactionProcessing.ApiDefaults/ApiDefaultsExtensions.cs @@ -0,0 +1,81 @@ +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Scalar.AspNetCore; + +namespace TransactionProcessing.ApiDefaults; + +/// +/// Registers and maps the shared API versioning and documentation defaults. +/// +public static class ApiDefaultsExtensions +{ + /// + /// Registers URL-segment API versioning, version discovery, and OpenAPI generation. + /// + public static IServiceCollection AddApiDefaults( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(configure ?? (_ => { })); + services.AddApiVersioning(options => + { + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + options.DefaultApiVersion = ApiVersions.V1; + options.AssumeDefaultVersionWhenUnspecified = false; + options.ReportApiVersions = true; + }).AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }).AddOpenApi(options => + { + options.Document.AddDocumentTransformer((document, context, _) => + { + ApiDefaultsOptions metadata = context.ApplicationServices + .GetRequiredService>() + .Value; + document.Info.Title = metadata.Title; + document.Info.Description = metadata.Description; + return Task.CompletedTask; + }); + }); + services.AddTransient(); + + return services; + } + + /// + /// Maps OpenAPI documents at /openapi/{version}.json and Scalar at /scalar. + /// + public static WebApplication UseApiDefaults(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + app.DescribeApiVersions(); + app.MapOpenApi().WithDocumentPerVersion(); + app.MapScalarApiReference("/scalar", (options, context) => + { + ApiDefaultsOptions defaults = context.RequestServices + .GetRequiredService>() + .Value; + IApiVersionDescriptionProvider versions = context.RequestServices + .GetRequiredService(); + + options.WithTitle(defaults.Title) + .WithOpenApiRoutePattern("/openapi/{documentName}.json"); + + foreach (ApiVersionDescription version in versions.ApiVersionDescriptions) + { + options.AddDocument(version.GroupName, $"{defaults.Title} {version.GroupName.ToUpperInvariant()}"); + } + }); + + return app; + } +} diff --git a/TransactionProcessing.ApiDefaults/ApiDefaultsOptions.cs b/TransactionProcessing.ApiDefaults/ApiDefaultsOptions.cs new file mode 100644 index 0000000..570a9e2 --- /dev/null +++ b/TransactionProcessing.ApiDefaults/ApiDefaultsOptions.cs @@ -0,0 +1,13 @@ +namespace TransactionProcessing.ApiDefaults; + +/// +/// Documentation metadata applied to every generated API version document. +/// +public sealed class ApiDefaultsOptions +{ + /// Gets or sets the API title. + public string Title { get; set; } = "API"; + + /// Gets or sets the API description. + public string? Description { get; set; } +} diff --git a/TransactionProcessing.ApiDefaults/ApiVersions.cs b/TransactionProcessing.ApiDefaults/ApiVersions.cs new file mode 100644 index 0000000..1fe9690 --- /dev/null +++ b/TransactionProcessing.ApiDefaults/ApiVersions.cs @@ -0,0 +1,19 @@ +using Asp.Versioning; + +namespace TransactionProcessing.ApiDefaults; + +/// +/// Common API versions for endpoint declarations. OpenAPI discovery is independent of this type. +/// +public static class ApiVersions +{ + /// API version 1.0. + public static readonly ApiVersion V1 = new(1, 0); + + /// API version 2.0. + public static readonly ApiVersion V2 = new(2, 0); + + /// API version 3.0. + public static readonly ApiVersion V3 = new(3, 0); + public static readonly ApiVersion V4 = new(4, 0); +} diff --git a/TransactionProcessing.ApiDefaults/README.md b/TransactionProcessing.ApiDefaults/README.md new file mode 100644 index 0000000..bc4587f --- /dev/null +++ b/TransactionProcessing.ApiDefaults/README.md @@ -0,0 +1,52 @@ +# TransactionProcessing.ApiDefaults + +Shared URL-based API versioning, per-version OpenAPI documents, and Scalar UI for .NET 10 Minimal APIs. + +## Setup + +```csharp +using Asp.Versioning; +using TransactionProcessing.ApiDefaults; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddApiDefaults(options => +{ + options.Title = "Transaction API"; + options.Description = "Transaction processing services"; +}); + +var app = builder.Build(); +app.UseApiDefaults(); + +var api = app.NewVersionedApi("Transactions"); +var transactions = api + .MapGroup("/api/v{version:apiVersion}/transactions") + .HasApiVersion(ApiVersions.V1); +transactions.MapGet("/", GetTransactions).MapToApiVersion(ApiVersions.V1); + +app.Run(); +``` + +`ApiVersions.V1`, `.V2`, and `.V3` are convenience values. Additional API versions can be declared with `new ApiVersion(major, minor)`; OpenAPI documents and Scalar definitions are discovered from the mapped endpoints. + +## Version fallback + +For a route whose behavior only changes occasionally, map one handler per version where the behavior changes. The route uses the newest registered handler at or before the requested version: + +```csharp +transactions.MapGetWithVersionFallback("/", new Dictionary> +{ + [ApiVersions.V1] = _ => Results.Ok("V1 response"), + [ApiVersions.V2] = _ => Results.Ok("V2 response") +}); +``` + +If the route group also declares V3, V3 requests use the V2 handler automatically. Add a V3 entry only when the behavior changes; that entry then serves V3 and later versions until another handler is registered. `MapGetWithVersionFallbackAsync` provides the same behavior for asynchronous handlers. Versions earlier than the first registered handler return 404. + +## Routes + +- API endpoints use explicit URL versions, such as `/api/v1/transactions`. +- OpenAPI documents are served at `/openapi/v1.json`, `/openapi/v2.json`, and the route for each additional discovered version. +- Scalar is available at `/scalar` (redirects to `/scalar/` for relative document URLs). + +`ApiDefaultsOptions.Title` defaults to `API`; `Description` is optional. This package is independent of the existing `Shared` packages, so projects that do not reference it do not acquire its API-related dependencies. diff --git a/TransactionProcessing.ApiDefaults/TransactionProcessing.ApiDefaults.csproj b/TransactionProcessing.ApiDefaults/TransactionProcessing.ApiDefaults.csproj new file mode 100644 index 0000000..4a71aa8 --- /dev/null +++ b/TransactionProcessing.ApiDefaults/TransactionProcessing.ApiDefaults.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + TransactionProcessing.ApiDefaults + Shared API versioning and OpenAPI defaults for ASP.NET Core Minimal APIs. + README.md + ASP.NET Core;Minimal API;API versioning;OpenAPI;Scalar + + + + + + + + + + + + + + + diff --git a/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs b/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..af0130d --- /dev/null +++ b/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs @@ -0,0 +1,173 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace TransactionProcessing.ApiDefaults; + +internal sealed record MinimumApiVersionMetadata(ApiVersion Version); + +internal sealed class MinimumApiVersionApiDescriptionProvider : Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider +{ + public int Order => int.MaxValue; + + public void OnProvidersExecuting(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext context) + { + } + + public void OnProvidersExecuted(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext context) + { + for (int index = context.Results.Count - 1; index >= 0; index--) + { + Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription description = context.Results[index]; + MinimumApiVersionMetadata? minimumVersion = description.ActionDescriptor.EndpointMetadata + .OfType() + .LastOrDefault(); + ApiVersionMetadata? versionMetadata = description.ActionDescriptor.EndpointMetadata + .OfType() + .LastOrDefault(); + + if (minimumVersion is not null && versionMetadata is not null) + { + versionMetadata.Deconstruct(out ApiVersionModel apiModel, out ApiVersionModel endpointModel, out string name); + static bool IsAvailable(ApiVersion version, ApiVersion first) => version.CompareTo(first) >= 0; + ApiVersionModel filteredEndpointModel = new( + endpointModel.DeclaredApiVersions.Where(version => IsAvailable(version, minimumVersion.Version)), + endpointModel.SupportedApiVersions.Where(version => IsAvailable(version, minimumVersion.Version)), + endpointModel.DeprecatedApiVersions.Where(version => IsAvailable(version, minimumVersion.Version)), + [], + []); + + if (!filteredEndpointModel.DeclaredApiVersions.Any() && + !filteredEndpointModel.SupportedApiVersions.Any() && + !filteredEndpointModel.DeprecatedApiVersions.Any()) + { + context.Results.RemoveAt(index); + continue; + } + + int metadataIndex = description.ActionDescriptor.EndpointMetadata.IndexOf(versionMetadata); + description.ActionDescriptor.EndpointMetadata[metadataIndex] = new ApiVersionMetadata(apiModel, filteredEndpointModel, name); + } + } + } +} + +/// +/// Maps GET endpoints that inherit the newest handler declared for an earlier API version. +/// +public static class VersionFallbackEndpointRouteBuilderExtensions +{ + /// + /// Maps a GET endpoint that uses the handler for the highest declared API version less than + /// or equal to the requested version. The endpoint inherits the API versions of its route group. + /// + /// The versioned route group. + /// The route pattern. + /// Handlers keyed by the API version in which they were introduced. + /// The mapped endpoint builder. + /// + /// A handler registered for version 2 is used for versions 2 and later until a later handler + /// is registered. Versions earlier than the first handler return 404. + /// + public static RouteHandlerBuilder MapGetWithVersionFallback( + this IEndpointRouteBuilder endpoints, + string pattern, + IReadOnlyDictionary> handlers) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(pattern); + ArgumentNullException.ThrowIfNull(handlers); + + SortedDictionary> orderedHandlers = CreateOrderedHandlers(handlers); + + RouteHandlerBuilder endpoint = endpoints.MapGet(pattern, (HttpContext context) => + { + ApiVersion? requestedVersion = context.ApiVersioningFeature.RequestedApiVersion; + if (requestedVersion is null) + { + return Results.NotFound(); + } + + foreach ((ApiVersion version, Func handler) in orderedHandlers.Reverse()) + { + if (version.CompareTo(requestedVersion) <= 0) + { + return handler(context); + } + } + + return Results.NotFound(); + }); + + endpoint.WithMetadata(new MinimumApiVersionMetadata(orderedHandlers.Keys.First())); + return endpoint; + } + + /// + /// Maps an asynchronous GET endpoint that uses the handler for the highest declared API version + /// less than or equal to the requested version. The endpoint inherits the API versions of its route group. + /// + /// The versioned route group. + /// The route pattern. + /// Handlers keyed by the API version in which they were introduced. + /// The mapped endpoint builder. + /// + /// A handler registered for version 2 is used for versions 2 and later until a later handler + /// is registered. Versions earlier than the first handler return 404. + /// + public static RouteHandlerBuilder MapGetWithVersionFallbackAsync( + this IEndpointRouteBuilder endpoints, + string pattern, + IReadOnlyDictionary>> handlers) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(pattern); + ArgumentNullException.ThrowIfNull(handlers); + + SortedDictionary>> orderedHandlers = CreateOrderedHandlers(handlers); + + RouteHandlerBuilder endpoint = endpoints.MapGet(pattern, async (HttpContext context, CancellationToken cancellationToken) => + { + ApiVersion? requestedVersion = context.ApiVersioningFeature.RequestedApiVersion; + if (requestedVersion is null) + { + return Results.NotFound(); + } + + foreach ((ApiVersion version, Func> handler) in orderedHandlers.Reverse()) + { + if (version.CompareTo(requestedVersion) <= 0) + { + return await handler(context, cancellationToken).ConfigureAwait(false); + } + } + + return Results.NotFound(); + }); + + endpoint.WithMetadata(new MinimumApiVersionMetadata(orderedHandlers.Keys.First())); + return endpoint; + } + + private static SortedDictionary CreateOrderedHandlers( + IReadOnlyDictionary handlers) + where THandler : Delegate + { + if (handlers.Count == 0) + { + throw new ArgumentException("At least one versioned handler is required.", nameof(handlers)); + } + + SortedDictionary orderedHandlers = new(); + foreach ((ApiVersion version, THandler handler) in handlers) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(handler); + orderedHandlers.Add(version, handler); + } + + return orderedHandlers; + } +} + From 29f8a0278d9efc93fd4ac1522c6ac119ccbe85cd Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 19 Oct 2026 15:04:37 +0100 Subject: [PATCH 2/2] Address Codacy review comments --- .../ApiDefaultsIntegrationTests.cs | 34 ++++++++++--------- ...nFallbackEndpointRouteBuilderExtensions.cs | 1 + 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs b/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs index 081e68a..536c717 100644 --- a/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs +++ b/TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs @@ -15,6 +15,8 @@ namespace TransactionProcessing.ApiDefaults.Tests; public sealed class ApiDefaultsIntegrationTests { + private const string OpenApiPathsPropertyName = "paths"; + [Fact] public void ApiDefaultsOptions_HasExpectedDefaults() { @@ -99,22 +101,22 @@ public async Task OpenApiDocuments_ContainOnlyApplicableEndpointsAndMetadata() v1.RootElement.GetProperty("info").GetProperty("title").GetString().ShouldBe("Test Transactions API"); v1.RootElement.GetProperty("info").GetProperty("description").GetString().ShouldBe("API defaults integration test."); - v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions/shared"); - v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions"); - v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions/v1"); - v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/v2"); - v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/async"); - v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/shared"); - v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/v2"); - v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/async"); - v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v2/transactions/v1"); - v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/shared"); - v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions"); - v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/v3"); - v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/async"); - v1.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/future-only"); - v2.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v2/transactions/future-only"); - v3.RootElement.GetProperty("paths").EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v3/transactions/future-only"); + v1.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions/shared"); + v1.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions"); + v1.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v1/transactions/v1"); + v1.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/v2"); + v1.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/async"); + v2.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/shared"); + v2.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/v2"); + v2.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v2/transactions/async"); + v2.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v2/transactions/v1"); + v3.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/shared"); + v3.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions"); + v3.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/v3"); + v3.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldContain("/api/v3/transactions/async"); + v1.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v1/transactions/future-only"); + v2.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v2/transactions/future-only"); + v3.RootElement.GetProperty(OpenApiPathsPropertyName).EnumerateObject().Select(x => x.Name).ShouldNotContain("/api/v3/transactions/future-only"); } [Fact] diff --git a/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs b/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs index af0130d..1699923 100644 --- a/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs +++ b/TransactionProcessing.ApiDefaults/VersionFallbackEndpointRouteBuilderExtensions.cs @@ -13,6 +13,7 @@ internal sealed class MinimumApiVersionApiDescriptionProvider : Microsoft.AspNet public void OnProvidersExecuting(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext context) { + // Version applicability is adjusted after API descriptions have been generated. } public void OnProvidersExecuted(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext context)