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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/createrelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 6 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AspNetCore.HealthChecks.Uris" Version="9.0.0" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageVersion Include="Asp.Versioning.OpenApi" Version="10.2.3" />
<PackageVersion Include="coverlet.MTP" Version="10.1.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.13" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.1.0" />
<PackageVersion Include="SimpleResults" Version="4.0.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.18.0" />
<PackageVersion Include="SocketIOClient" Version="4.0.5" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageVersion Include="xunit.v3" Version="4.0.2" />
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
20 changes: 19 additions & 1 deletion Shared.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.0.11205.157
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions TransactionProcessing.ApiDefaults.Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ApiVersion, Func<HttpContext, IResult>>
{
[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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\TransactionProcessing.ApiDefaults\TransactionProcessing.ApiDefaults.csproj" />
</ItemGroup>

</Project>
195 changes: 195 additions & 0 deletions TransactionProcessing.ApiDefaults.Tests/ApiDefaultsIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
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
{
private const string OpenApiPathsPropertyName = "paths";

[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(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]
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<WebApplication> 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<ApiVersion, Func<HttpContext, IResult>>
{
[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<ApiVersion, Func<HttpContext, CancellationToken, Task<IResult>>>
{
[ApiVersions.V2] = async (_, cancellationToken) =>
{
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
return Results.Text("async-v2");
}
});
transactions.MapGetWithVersionFallback("/future-only", new Dictionary<ApiVersion, Func<HttpContext, IResult>>
{
[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;
}
}
Loading
Loading