diff --git a/.github/workflows/build-and-test-aot.yml b/.github/workflows/build-and-test-aot.yml index 9b2e8a6..745a6f6 100644 --- a/.github/workflows/build-and-test-aot.yml +++ b/.github/workflows/build-and-test-aot.yml @@ -39,3 +39,13 @@ jobs: - name: Run AOT Compiled Application (${{ matrix.os }}) shell: bash # Ensure bash is used on Windows as well for consistent pathing syntax (./) run: ./publish/Tests.AotConsole + + - name: Restore ASP.NET Core AOT test dependencies + run: dotnet restore src/AspNetCore.Tests.AotConsole/AspNetCore.Tests.AotConsole.csproj -p:Configuration=CI-Release --use-current-runtime -p:SelfContained=true + + - name: Build and Publish ASP.NET Core AOT Test (${{ matrix.os }}) + run: dotnet publish src/AspNetCore.Tests.AotConsole/AspNetCore.Tests.AotConsole.csproj --configuration CI-Release --no-restore --use-current-runtime --self-contained true -o ./publish-aspnetcore + + - name: Run ASP.NET Core AOT Test (${{ matrix.os }}) + shell: bash + run: ./publish-aspnetcore/AspNetCore.Tests.AotConsole diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f21c1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Overview + +ByteAether.Ulid is a multi-target .NET ULID library with companion packages. Use shared build settings and package versions; keep package dependencies compatible with declared target frameworks. NuGet releases pack from `v`-prefixed tags. + +## Structure + +`src/ByteAether.Ulid.slnx` is the solution. Shared settings are in `src/Directory.Build.{props,targets}` and `src/Directory.Packages.props`. Core code/tests: `src/Ulid/`, `src/Ulid.Tests/`; companion packages have separate projects and tests, reference core by project reference, and reuse shared code where appropriate. Package docs are `PACKAGE.md`; shared docs are in root `README.md`. Core library AOT smoke app: `src/Ulid.Tests.AotConsole/`; Core library benchmarks: `src/Ulid.Benchmarks/`. + +## Build and test + +Use .NET 10. From the repository root, CI build commands are: + +```sh +dotnet restore src/ByteAether.Ulid.slnx -p:Configuration=CI-Debug +dotnet build src/ByteAether.Ulid.slnx --configuration CI-Debug --no-restore +``` + +Test hosts generally target `net10.0`; `ImplTargetFramework` selects the referenced implementation TFM, not the test host, and shared targets set matching compile constants. Each test project's `SupportedImplFrameworks` must match supported implementation `TargetFrameworks`; unsupported selections are skipped. Keep lists and `.github/workflows/build-and-test.yml` aligned. CI tests `net10.0;net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.1;netstandard2.0`. For one TFM: + +```sh +dotnet test src/ByteAether.Ulid.slnx --configuration CI-Debug -p:ImplTargetFramework=net8.0 --results-directory TestResults +``` + +TRX output is under `TestResults/`; unconditional test projects may run on every matrix pass. Shared test setup uses xUnit v3 and Microsoft.Testing.Platform TRX reporting; follow existing dependency patterns. + +Native AOT (`.github/workflows/build-and-test-aot.yml`) is validated on Linux and Windows with warnings as errors: + +```sh +dotnet restore src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj -p:Configuration=CI-Release --use-current-runtime -p:SelfContained=true +dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj --configuration CI-Release --no-restore --use-current-runtime --self-contained true -o ./publish +./publish/Tests.AotConsole +``` + +## C# conventions + +- Always use file-scoped namespaces. Follow `src/.editorconfig`: tabs, CRLF, braces, naming. Nullable and implicit usings are enabled. +- For multiline arguments/conditions, put each item on a new line, indented one tab; align the closing `)` with the construct: + ```csharp + Call( + firstArgument, + secondArgument + ); + ``` +- Preserve all target-framework compatibility; use `src/Compatibility/` where needed. Add/update tests for behavior changes and cover relevant `SupportedImplFrameworks`. +- Omit contextually implied domain terms from identifier and file names (e.g., `Transformer` over `UlidTransformer`), reserving specific prefixes solely for disambiguating multiple types in the same scope (e.g., `UlidConstraint` vs. `GuidConstraint`). +- Add/update tests for behavior changes and cover relevant `SupportedImplFrameworks`. diff --git a/README.md b/README.md index f0ab4c6..4775886 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ This library explicitly **multi-targets** each runtime version listed below, ena `ByteAether.Ulid.linq2db` * 📦 **[Dapper](#dapper-integration--byteaetheruliddapper)** `ByteAether.Ulid.Dapper` +* 📦 **[ASP.NET Core](#aspnet-core)** + `ByteAether.Ulid.AspNetCore` * 📦 **[CLI & .NET Tool](#net-tool--cli-integration--byteaetherulidcli)** `ByteAether.Ulid.Cli` @@ -311,9 +313,44 @@ Custom `IRandomProvider` implementations can also be created. ## 🔌 Integration with Other Libraries -### ASP.NET Core +### ASP.NET Core Integration – ByteAether.Ulid.AspNetCore -Supports seamless integration as a route or query parameter with built-in `TypeConverter`. +[](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/) + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +![ASP.NET Core 6.0+](https://img.shields.io/badge/ASP.NET_Core-6.0+-orange) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.AspNetCore?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.AspNetCore?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/) + +**This companion package is optional.** The core `ByteAether.Ulid` library already supports ULIDs as ASP.NET Core route and query parameters through its built-in `TypeConverter`, and serializes ULIDs as strings with `System.Text.Json`. Install the companion package only if you need its additional ASP.NET Core integrations: + +- A `:ulid` route constraint that matches only valid ULIDs. +- Typed `Ulid` route link generation. +- Optional OpenAPI schema support for Swashbuckle or built-in ASP.NET Core OpenAPI. + +To use these additional features, install the package: + +```sh +dotnet add package ByteAether.Ulid.AspNetCore +``` + +Then register the `ulid` route constraint during application startup: + +```csharp +using ByteAether.Ulid; +using ByteAether.Ulid.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddUlidRouteConstraint(); + +var app = builder.Build(); +app.MapGet("/users/{id:ulid}", (Ulid id) => Results.Ok(id)); +app.Run(); +``` + +The package depends on `ByteAether.Ulid`. OpenAPI packages remain optional and are not added transitively. + +More details in the package's [PACKAGE.md](./src/AspNetCore/PACKAGE.md) file. ### System.Text.Json (.NET 5.0+) diff --git a/assets/logo_ulid_aspnetcore.png b/assets/logo_ulid_aspnetcore.png new file mode 100644 index 0000000..8b920bc Binary files /dev/null and b/assets/logo_ulid_aspnetcore.png differ diff --git a/src/AspNetCore.IntegrationTests.OpenApi/AspNetCore.IntegrationTests.OpenApi.csproj b/src/AspNetCore.IntegrationTests.OpenApi/AspNetCore.IntegrationTests.OpenApi.csproj new file mode 100644 index 0000000..5484a26 --- /dev/null +++ b/src/AspNetCore.IntegrationTests.OpenApi/AspNetCore.IntegrationTests.OpenApi.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + net10.0;net9.0;net8.0;net7.0;net6.0 + + true + ByteAether.Ulid.AspNetCore.IntegrationTests.OpenApi + $(DefineConstants);IMPL_NET9_OR_GREATER + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + + + + + + + + + + + diff --git a/src/AspNetCore.IntegrationTests.OpenApi/OpenApiSchemaAssertions.cs b/src/AspNetCore.IntegrationTests.OpenApi/OpenApiSchemaAssertions.cs new file mode 100644 index 0000000..cfc8a31 --- /dev/null +++ b/src/AspNetCore.IntegrationTests.OpenApi/OpenApiSchemaAssertions.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace ByteAether.Ulid.AspNetCore.IntegrationTests.OpenApi; + +internal static class OpenApiSchemaAssertions +{ + public static void AssertUlidSchemas(string document) + { + using var json = JsonDocument.Parse(document); + var schemas = new List(); + FindUlidSchemas(json.RootElement, schemas); + + Assert.NotEmpty(schemas); + foreach (var schema in schemas) + { + Assert.Equal("string", schema.GetProperty("type").GetString()); + Assert.Equal("ulid", schema.GetProperty("format").GetString()); + Assert.Equal(Ulid.UlidStringLength, schema.GetProperty("minLength").GetInt32()); + Assert.Equal(Ulid.UlidStringLength, schema.GetProperty("maxLength").GetInt32()); + Assert.False(schema.TryGetProperty("properties", out _)); + Assert.False(schema.TryGetProperty("items", out _)); + Assert.False(schema.TryGetProperty("required", out _)); + } + } + + private static void FindUlidSchemas(JsonElement element, List schemas) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty("format", out var format) && format.GetString() == "ulid") + { + schemas.Add(element); + } + + foreach (var property in element.EnumerateObject()) + { + FindUlidSchemas(property.Value, schemas); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + FindUlidSchemas(item, schemas); + } + } + } +} diff --git a/src/AspNetCore.IntegrationTests.OpenApi/OpenApiTests.cs b/src/AspNetCore.IntegrationTests.OpenApi/OpenApiTests.cs new file mode 100644 index 0000000..e5dae3e --- /dev/null +++ b/src/AspNetCore.IntegrationTests.OpenApi/OpenApiTests.cs @@ -0,0 +1,57 @@ +#if IMPL_NET9_OR_GREATER +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +// ReSharper disable RouteTemplates.RouteParameterConstraintNotResolved + +namespace ByteAether.Ulid.AspNetCore.IntegrationTests.OpenApi; + +public class OpenApiTests +{ + private sealed record UlidPayload(Ulid Id, Ulid? OptionalId = null); + private sealed record UlidResponse(Ulid RouteId, Ulid Id, Ulid? OptionalId); + + [Fact] + public async Task UlidRouteAndJsonPayloads_RoundTripAndAppearInGeneratedDocument() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddUlidRouteConstraint(); + builder.Services.AddOpenApi(options => options.AddUlidSchemaTransformer()); + + await using var app = builder.Build(); + app.MapOpenApi(); + app.MapPost("/ulids/{id:ulid}", (Ulid id, UlidPayload payload) => + TypedResults.Ok(new UlidResponse(id, payload.Id, payload.OptionalId))); + + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + + var expected = Ulid.New(); + using var response = await client.PostAsJsonAsync( + $"/ulids/{expected}", + new UlidPayload(expected), + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var returnedPayload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(expected, returnedPayload?.Id); + Assert.Equal(expected, returnedPayload?.RouteId); + Assert.Null(returnedPayload?.OptionalId); + + using var invalidRouteResponse = await client.PostAsJsonAsync( + "/ulids/not-a-ulid", + new UlidPayload(expected), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, invalidRouteResponse.StatusCode); + + using var openApiResponse = await client.GetAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + openApiResponse.EnsureSuccessStatusCode(); + var document = await openApiResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + OpenApiSchemaAssertions.AssertUlidSchemas(document); + } +} +#endif \ No newline at end of file diff --git a/src/AspNetCore.IntegrationTests.OpenApi/SwashbuckleIntegrationTests.cs b/src/AspNetCore.IntegrationTests.OpenApi/SwashbuckleIntegrationTests.cs new file mode 100644 index 0000000..391ee07 --- /dev/null +++ b/src/AspNetCore.IntegrationTests.OpenApi/SwashbuckleIntegrationTests.cs @@ -0,0 +1,38 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +// ReSharper disable RouteTemplates.RouteParameterConstraintNotResolved + +namespace ByteAether.Ulid.AspNetCore.IntegrationTests.OpenApi; + +public class SwashbuckleTests +{ + // ReSharper disable NotAccessedPositionalProperty.Local + private sealed record UlidResponse(Ulid Id, Ulid? OptionalId); + // ReSharper restore NotAccessedPositionalProperty.Local + + [Fact] + public async Task UlidAndNullableUlidSchemasAreGeneratedAs26CharacterStrings() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddUlidRouteConstraint(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => options.SchemaFilter()); + + await using var app = builder.Build(); + app.UseSwagger(); + app.MapGet("/ulids/{id:ulid}", (Ulid id) => TypedResults.Ok(new UlidResponse(id, id))); + + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + + using var response = await client.GetAsync("/swagger/v1/swagger.json", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var document = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("\"optionalId\":", document, StringComparison.Ordinal); + OpenApiSchemaAssertions.AssertUlidSchemas(document); + } +} \ No newline at end of file diff --git a/src/AspNetCore.IntegrationTests/AspNetCore.IntegrationTests.csproj b/src/AspNetCore.IntegrationTests/AspNetCore.IntegrationTests.csproj new file mode 100644 index 0000000..5f78475 --- /dev/null +++ b/src/AspNetCore.IntegrationTests/AspNetCore.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + net10.0;net9.0;net8.0;net7.0;net6.0 + + true + ByteAether.Ulid.AspNetCore.IntegrationTests + + + + + + + + + + + diff --git a/src/AspNetCore.IntegrationTests/RouteAndJsonTests.cs b/src/AspNetCore.IntegrationTests/RouteAndJsonTests.cs new file mode 100644 index 0000000..5a0d3c2 --- /dev/null +++ b/src/AspNetCore.IntegrationTests/RouteAndJsonTests.cs @@ -0,0 +1,114 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +// ReSharper disable RouteTemplates.RouteParameterConstraintNotResolved + +namespace ByteAether.Ulid.AspNetCore.IntegrationTests; + +public class RouteAndJsonTests +{ + private sealed record UlidPayload(Ulid Id, Ulid? OptionalId); + private sealed record UlidResponse(Ulid RouteId, Ulid Id, Ulid? OptionalId); + + [Fact] + public async Task RoutesAndJsonWorkWithoutOpenApiPackages() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddUlidRouteConstraint(); + + await using var app = builder.Build(); + app.MapPost("/ulids/{id:ulid}", (Ulid id, UlidPayload payload) => + TypedResults.Ok(new UlidResponse(id, payload.Id, payload.OptionalId))); + + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + var expected = Ulid.New(); + + using var validResponse = await client.PostAsJsonAsync( + $"/ulids/{expected}", + new UlidPayload(expected, null), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, validResponse.StatusCode); + var returnedPayload = await validResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(expected, returnedPayload?.RouteId); + Assert.Equal(expected, returnedPayload?.Id); + Assert.Null(returnedPayload?.OptionalId); + using var responseJson = JsonDocument.Parse(await validResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(expected.ToString(), responseJson.RootElement.GetProperty("id").GetString()); + Assert.Equal(JsonValueKind.Null, responseJson.RootElement.GetProperty("optionalId").ValueKind); + + using var invalidResponse = await client.PostAsJsonAsync( + "/ulids/not-a-ulid", + new UlidPayload(expected, null), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, invalidResponse.StatusCode); + + using var invalidJsonResponse = await client.PostAsJsonAsync( + $"/ulids/{expected}", + new { Id = "not-a-ulid", OptionalId = (string?)null }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, invalidJsonResponse.StatusCode); + } + + [Fact] + public async Task UlidRouteValuesAndQueryParametersAreSupported() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddUlidRouteConstraint(); + + await using var app = builder.Build(); + app.MapGet("/ulids/{id:ulid}", (Ulid id) => TypedResults.Ok(id)) + .WithName("GetUlid"); + app.MapGet("/lookup", (Ulid id) => TypedResults.Ok(id)); + + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + var expected = Ulid.New(); + var linkGenerator = app.Services.GetRequiredService(); + + var path = linkGenerator.GetPathByName("GetUlid", new { id = expected }); + Assert.Equal($"/ulids/{expected}", path); + + using var routeResponse = await client.GetAsync(path!, TestContext.Current.CancellationToken); + Assert.Equal(expected, await routeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)); + + using var queryResponse = await client.GetAsync($"/lookup?id={expected}", TestContext.Current.CancellationToken); + Assert.Equal(expected, await queryResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task MvcModelBindingAcceptsUlidStrings() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddControllers().AddApplicationPart(typeof(UlidModelBindingController).Assembly); + + await using var app = builder.Build(); + app.MapControllers(); + + await app.StartAsync(TestContext.Current.CancellationToken); + var expected = Ulid.New(); + using var response = await app.GetTestClient().GetAsync( + $"/mvc-ulids/{expected}", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expected, await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken)); + } +} + +[ApiController] +[Route("mvc-ulids")] +public sealed class UlidModelBindingController : ControllerBase +{ + [HttpGet("{id}")] + public Ulid Get(Ulid id) => id; +} \ No newline at end of file diff --git a/src/AspNetCore.Tests.AotConsole/AspNetCore.Tests.AotConsole.csproj b/src/AspNetCore.Tests.AotConsole/AspNetCore.Tests.AotConsole.csproj new file mode 100644 index 0000000..1373cab --- /dev/null +++ b/src/AspNetCore.Tests.AotConsole/AspNetCore.Tests.AotConsole.csproj @@ -0,0 +1,19 @@ + + + + exe + net10.0 + true + false + true + true + + ByteAether.Ulid.AspNetCore.Tests.AotConsole + + + + + + + + diff --git a/src/AspNetCore.Tests.AotConsole/Program.cs b/src/AspNetCore.Tests.AotConsole/Program.cs new file mode 100644 index 0000000..9022356 --- /dev/null +++ b/src/AspNetCore.Tests.AotConsole/Program.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using ByteAether.Ulid; +using ByteAether.Ulid.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +// ReSharper disable RouteTemplates.RouteParameterConstraintNotResolved + +var builder = WebApplication.CreateBuilder(); +builder.WebHost.UseUrls("http://127.0.0.1:0"); +builder.Services.AddUlidRouteConstraint(); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, UlidJsonContext.Default)); + +await using var app = builder.Build(); +app.MapGet("/ulids/{id:ulid}", (Ulid id) => TypedResults.Ok(id)); +await app.StartAsync(); + +var addresses = app.Services.GetRequiredService() + .Features.Get()?.Addresses; +if (addresses is null) +{ + throw new InvalidOperationException("Could not get the ASP.NET Core server address."); +} + +using var client = new HttpClient(); +var expected = Ulid.New(); +using var response = await client.GetAsync($"{addresses.Single()}/ulids/{expected}"); +response.EnsureSuccessStatusCode(); +var returned = JsonSerializer.Deserialize( + await response.Content.ReadAsStringAsync(), + UlidJsonContext.Default.Ulid); +if (returned != expected) +{ + throw new InvalidOperationException("The ASP.NET Core ULID endpoint returned an unexpected value."); +} + +using var invalidResponse = await client.GetAsync($"{addresses.Single()}/ulids/not-a-ulid"); +if (invalidResponse.StatusCode != HttpStatusCode.NotFound) +{ + throw new InvalidOperationException("The ASP.NET Core ULID route constraint accepted an invalid value."); +} + +[JsonSerializable(typeof(Ulid))] +internal partial class UlidJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/src/AspNetCore/AspNetCore.Impl.csproj b/src/AspNetCore/AspNetCore.Impl.csproj new file mode 100644 index 0000000..ba9f2e4 --- /dev/null +++ b/src/AspNetCore/AspNetCore.Impl.csproj @@ -0,0 +1,47 @@ + + + + false + net10.0;net9.0;net8.0;net7.0;net6.0 + library + true + NU1903 + + true + true + ByteAether.Ulid.AspNetCore - ULID route constraints, JSON, and OpenAPI support for ASP.NET Core + Official ASP.NET Core integration for ByteAether.Ulid. Adds ULID route constraints, model binding support, JSON serialization for `Ulid` values, and optional OpenAPI/Swagger schema metadata for Minimal APIs, MVC, and web apps. + + ByteAether.Ulid.AspNetCore + logo_ulid_aspnetcore.png + ulid;aspnetcore;asp-net-core;route-constraint;model-binding;minimal-api;openapi;swagger;json;web;csharp + PACKAGE.md + + ByteAether.Ulid.AspNetCore + ByteAether.Ulid.AspNetCore + + + + + + + + + + + + + + + + + + + + + True + \ + + + + diff --git a/src/AspNetCore/OpenApiOptionsExtensions.cs b/src/AspNetCore/OpenApiOptionsExtensions.cs new file mode 100644 index 0000000..f23faca --- /dev/null +++ b/src/AspNetCore/OpenApiOptionsExtensions.cs @@ -0,0 +1,22 @@ +#if NET9_0_OR_GREATER +using Microsoft.AspNetCore.OpenApi; + +namespace ByteAether.Ulid.AspNetCore; + +/// +/// Provides OpenAPI configuration for . +/// +public static class OpenApiOptionsExtensions +{ + /// + /// Adds the ULID schema transformer to the OpenAPI document configuration. + /// + public static OpenApiOptions AddUlidSchemaTransformer(this OpenApiOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.AddSchemaTransformer(); + return options; + } +} +#endif \ No newline at end of file diff --git a/src/AspNetCore/PACKAGE.md b/src/AspNetCore/PACKAGE.md new file mode 100644 index 0000000..906dfb3 --- /dev/null +++ b/src/AspNetCore/PACKAGE.md @@ -0,0 +1,113 @@ +# ULID Integration for ASP.NET Core +*from ByteAether* + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +![.NET 10.0](https://img.shields.io/badge/.NET-10.0-brightgreen) +![.NET 9.0](https://img.shields.io/badge/.NET-9.0-brightgreen) +![.NET 8.0](https://img.shields.io/badge/.NET-8.0-brightgreen) +![.NET 7.0](https://img.shields.io/badge/.NET-7.0-brightgreen) +![.NET 6.0](https://img.shields.io/badge/.NET-6.0-brightgreen) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.AspNetCore?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.AspNetCore?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/) + +Adds ASP.NET Core support for `ByteAether.Ulid`. + +## ✨ Features + +- **Route constraints**: register a `:ulid` route parameter policy that accepts only valid ULID values. +- **JSON support**: `Ulid` and `Ulid?` serialize as canonical 26-character strings without extra configuration in reflection-enabled applications. +- **OpenAPI support**: expose ULIDs as strings with the `ulid` format in Swashbuckle and ASP.NET Core OpenAPI. +- **AOT-friendly**: compatible with Native AOT; ASP.NET Core source-generated JSON metadata is required for application types. + +For the core library and full details, see the [GitHub repository](https://github.com/ByteAether/Ulid). + +## 💾 Install + +```sh +dotnet add package ByteAether.Ulid.AspNetCore +``` + +The package depends on `ByteAether.Ulid`. OpenAPI packages are optional and are not added transitively. + +## 🚀 Routes and JSON + +No extra packages are required for route constraints or JSON serialization. + +```csharp +using ByteAether.Ulid; +using ByteAether.Ulid.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddUlidRouteConstraint(); + +var app = builder.Build(); +app.MapGet("/users/{id:ulid}", (Ulid id) => Results.Ok(new UserResponse(id))); +app.Run(); + +public record UserResponse(Ulid Id); +``` + +Behavior: + +- route parameters must match a valid ULID, otherwise the route does not match +- Minimal API and MVC model binding accept valid ULID route and query values +- link generation accepts `Ulid` values for `:ulid` route parameters +- `Ulid` values in JSON bodies and responses use canonical 26-character strings; nullable values remain JSON `null` + +### Native AOT JSON + +For Native AOT Minimal APIs, include `Ulid` and every request/response type in a source-generated `JsonSerializerContext`, then register that context: + +```csharp +using ByteAether.Ulid; +using System.Text.Json.Serialization; + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default)); + +[JsonSerializable(typeof(Ulid))] +[JsonSerializable(typeof(UserResponse))] +internal partial class AppJsonSerializerContext : JsonSerializerContext +{ +} +``` + +## 📘 Optional OpenAPI support + +Choose the OpenAPI integration you already use. Install only that package; this package does not add either dependency automatically. + +### Built-in ASP.NET Core OpenAPI (.NET 9 and 10) + +```sh +dotnet add package Microsoft.AspNetCore.OpenApi +``` + +```csharp +builder.Services.AddOpenApi(options => options.AddUlidSchemaTransformer()); +app.MapOpenApi(); +``` + +For built-in OpenAPI, use the matching ASP.NET Core version: + +- .NET 9: `Microsoft.AspNetCore.OpenApi` 9.0.0 or newer +- .NET 10: `Microsoft.AspNetCore.OpenApi` 10.0.0 or newer + +### Swashbuckle (.NET 6–10) + +```sh +dotnet add package Swashbuckle.AspNetCore +``` + +```csharp +builder.Services.AddSwaggerGen(options => options.SchemaFilter()); +app.UseSwagger(); +``` + +For Swashbuckle, use a version compatible with your target framework: + +- .NET 6–9: 6.0.0 or newer +- .NET 10: 10.0.0 or newer + +## 📜 License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/ByteAether/Ulid/blob/main/LICENSE) file for details. diff --git a/src/AspNetCore/RouteConstraint.cs b/src/AspNetCore/RouteConstraint.cs new file mode 100644 index 0000000..0f6afae --- /dev/null +++ b/src/AspNetCore/RouteConstraint.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace ByteAether.Ulid.AspNetCore; + +/// +/// Constrains a route parameter to a valid ULID. +/// +internal sealed class RouteConstraint : IRouteConstraint +{ + /// + public bool Match( + HttpContext? httpContext, + IRouter? route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection) + => values.TryGetValue(routeKey, out var value) + && ( + value is string ulidString && Ulid.TryParse(ulidString, provider: null, out _) + || routeDirection == RouteDirection.UrlGeneration && value is Ulid + ); +} \ No newline at end of file diff --git a/src/AspNetCore/SchemaHelper.cs b/src/AspNetCore/SchemaHelper.cs new file mode 100644 index 0000000..feba317 --- /dev/null +++ b/src/AspNetCore/SchemaHelper.cs @@ -0,0 +1,28 @@ +#if NET10_0_OR_GREATER +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif + +namespace ByteAether.Ulid.AspNetCore; + +internal static class SchemaHelper +{ + internal static bool IsUlid(Type type) + => (Nullable.GetUnderlyingType(type) ?? type) == typeof(Ulid); + + internal static void ApplyUlidSchema(OpenApiSchema schema) + { +#if NET10_0_OR_GREATER + schema.Type = JsonSchemaType.String; +#else + schema.Type = "string"; +#endif + schema.Format = "ulid"; + schema.MinLength = Ulid.UlidStringLength; + schema.MaxLength = Ulid.UlidStringLength; + schema.Properties?.Clear(); + schema.Required?.Clear(); + schema.Items = null; + } +} diff --git a/src/AspNetCore/SchemaTransformer.cs b/src/AspNetCore/SchemaTransformer.cs new file mode 100644 index 0000000..3dce6a8 --- /dev/null +++ b/src/AspNetCore/SchemaTransformer.cs @@ -0,0 +1,31 @@ +#if NET9_0_OR_GREATER +using Microsoft.AspNetCore.OpenApi; +#if NET10_0_OR_GREATER +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif + +namespace ByteAether.Ulid.AspNetCore; + +/// +/// Represents ULID values as 26-character strings in OpenAPI documents. +/// +internal sealed class SchemaTransformer : IOpenApiSchemaTransformer +{ + /// + public Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + if (!SchemaHelper.IsUlid(context.JsonTypeInfo.Type)) + { + return Task.CompletedTask; + } + + SchemaHelper.ApplyUlidSchema(schema); + return Task.CompletedTask; + } +} +#endif \ No newline at end of file diff --git a/src/AspNetCore/ServiceCollectionExtensions.cs b/src/AspNetCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..df1d248 --- /dev/null +++ b/src/AspNetCore/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace ByteAether.Ulid.AspNetCore; + +/// +/// Provides ASP.NET Core registration for . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the ulid route constraint. + /// + public static IServiceCollection AddUlidRouteConstraint(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(options => + { +#if NET7_0_OR_GREATER + options.SetParameterPolicy("ulid"); +#else +#pragma warning disable IL2026 + options.ConstraintMap["ulid"] = typeof(RouteConstraint); +#pragma warning restore IL2026 +#endif + }); + + return services; + } +} \ No newline at end of file diff --git a/src/AspNetCore/UlidSchemaFilter.cs b/src/AspNetCore/UlidSchemaFilter.cs new file mode 100644 index 0000000..2a83715 --- /dev/null +++ b/src/AspNetCore/UlidSchemaFilter.cs @@ -0,0 +1,44 @@ +#if NET10_0_OR_GREATER +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ByteAether.Ulid.AspNetCore; + +/// +/// Represents ULID values as 26-character strings in Swashbuckle-generated OpenAPI documents. +/// +public sealed class UlidSchemaFilter : ISchemaFilter +{ + /// +#if NET10_0_OR_GREATER + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) +#else + public void Apply(OpenApiSchema schema, SchemaFilterContext context) +#endif + { + if (!SchemaHelper.IsUlid(context.Type)) + { + return; + } + +#if NET10_0_OR_GREATER + if (schema is OpenApiSchemaReference) + { + // The component schema was filtered before Swashbuckle returned this reference. + return; + } + + if (schema is not OpenApiSchema mutableSchema) + { + throw new InvalidOperationException($"Expected a mutable {nameof(OpenApiSchema)} for {nameof(Ulid)}, but received {schema.GetType().FullName}."); + } +#else + var mutableSchema = schema; +#endif + + SchemaHelper.ApplyUlidSchema(mutableSchema); + } +} diff --git a/src/ByteAether.Ulid.slnx b/src/ByteAether.Ulid.slnx index adda073..1d295ca 100644 --- a/src/ByteAether.Ulid.slnx +++ b/src/ByteAether.Ulid.slnx @@ -50,6 +50,18 @@ + + + + + + + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 115f37b..f6eed73 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -17,5 +17,6 @@ + diff --git a/src/Ulid/PACKAGE.md b/src/Ulid/PACKAGE.md index f079a9c..4e404dc 100644 --- a/src/Ulid/PACKAGE.md +++ b/src/Ulid/PACKAGE.md @@ -36,7 +36,11 @@ For more detailed documentation, visit our [GitHub repository](https://github.co `ByteAether.Ulid.linq2db` * 📦 **[Dapper](https://www.nuget.org/packages/ByteAether.Ulid.Dapper)** `ByteAether.Ulid.Dapper` - +* 📦 **[ASP.NET Core](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore)** + `ByteAether.Ulid.AspNetCore` +* 📦 **[CLI & .NET Tool](https://www.nuget.org/packages/ByteAether.Ulid.Cli)** + `ByteAether.Ulid.Cli` + ## 💾 Installation Install the latest stable package via NuGet: @@ -186,7 +190,7 @@ Custom `IRandomProvider` implementations can also be created. ### ASP.NET Core -Supports seamless integration as a route or query parameter with built-in `TypeConverter`. +The optional `ByteAether.Ulid.AspNetCore` companion library adds ASP.NET Core-specific conveniences such as ULID route constraints, OpenAPI schema metadata, and typed ASP.NET Core integration. Basic ASP.NET Core usage still works without it, because the core `Ulid` type already supports common model binding and JSON serialization scenarios out of the box. ### System.Text.Json