From 86332c3af11d39b4b298354556f9dfa471d571ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Sun, 4 Oct 2026 16:33:46 +0300 Subject: [PATCH 1/5] Added AGENTS.md with build, test, and coding conventions --- AGENTS.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 AGENTS.md 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`. From 4373d22a1f8f7b7df14a0a65ea93bead8cb5810b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 2 Oct 2026 00:09:53 +0300 Subject: [PATCH 2/5] Add ASP.NET Core integration package with ULID route constraint, OpenAPI support, and tests --- .github/workflows/build-and-test-aot.yml | 10 ++ README.md | 4 +- ...AspNetCore.IntegrationTests.OpenApi.csproj | 27 +++++ .../OpenApiSchemaAssertions.cs | 48 ++++++++ .../OpenApiTests.cs | 57 +++++++++ .../SwashbuckleIntegrationTests.cs | 38 ++++++ .../AspNetCore.IntegrationTests.csproj | 19 +++ .../RouteAndJsonTests.cs | 114 ++++++++++++++++++ .../AspNetCore.Tests.AotConsole.csproj | 19 +++ src/AspNetCore.Tests.AotConsole/Program.cs | 52 ++++++++ src/AspNetCore/AspNetCore.Impl.csproj | 45 +++++++ src/AspNetCore/OpenApiOptionsExtensions.cs | 22 ++++ src/AspNetCore/PACKAGE.md | 113 +++++++++++++++++ src/AspNetCore/RouteConstraint.cs | 23 ++++ src/AspNetCore/SchemaHelper.cs | 28 +++++ src/AspNetCore/SchemaTransformer.cs | 31 +++++ src/AspNetCore/ServiceCollectionExtensions.cs | 31 +++++ src/AspNetCore/UlidSchemaFilter.cs | 44 +++++++ src/ByteAether.Ulid.slnx | 12 ++ src/Directory.Packages.props | 1 + 20 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 src/AspNetCore.IntegrationTests.OpenApi/AspNetCore.IntegrationTests.OpenApi.csproj create mode 100644 src/AspNetCore.IntegrationTests.OpenApi/OpenApiSchemaAssertions.cs create mode 100644 src/AspNetCore.IntegrationTests.OpenApi/OpenApiTests.cs create mode 100644 src/AspNetCore.IntegrationTests.OpenApi/SwashbuckleIntegrationTests.cs create mode 100644 src/AspNetCore.IntegrationTests/AspNetCore.IntegrationTests.csproj create mode 100644 src/AspNetCore.IntegrationTests/RouteAndJsonTests.cs create mode 100644 src/AspNetCore.Tests.AotConsole/AspNetCore.Tests.AotConsole.csproj create mode 100644 src/AspNetCore.Tests.AotConsole/Program.cs create mode 100644 src/AspNetCore/AspNetCore.Impl.csproj create mode 100644 src/AspNetCore/OpenApiOptionsExtensions.cs create mode 100644 src/AspNetCore/PACKAGE.md create mode 100644 src/AspNetCore/RouteConstraint.cs create mode 100644 src/AspNetCore/SchemaHelper.cs create mode 100644 src/AspNetCore/SchemaTransformer.cs create mode 100644 src/AspNetCore/ServiceCollectionExtensions.cs create mode 100644 src/AspNetCore/UlidSchemaFilter.cs 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/README.md b/README.md index f0ab4c6..82cc096 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` @@ -313,7 +315,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 core library supports ULIDs as route and query parameters via its built-in `TypeConverter`, and uses `System.Text.Json` strings for request and response payloads. The [ASP.NET Core companion package](./src/AspNetCore/PACKAGE.md) adds a `:ulid` route constraint, typed route link generation, and optional OpenAPI schema support. ### System.Text.Json (.NET 5.0+) 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..f79f5a8 --- /dev/null +++ b/src/AspNetCore/AspNetCore.Impl.csproj @@ -0,0 +1,45 @@ + + + + false + net10.0;net9.0;net8.0;net7.0;net6.0 + library + true + NU1903 + + true + true + ByteAether.Ulid.AspNetCore - ASP.NET Core Integration for ULID + Official ASP.NET Core integration for ByteAether.Ulid. Provides a ULID route constraint and optional OpenAPI schema integration while supporting built-in request and response serialization and model binding. + + ByteAether.Ulid.AspNetCore + ulid;aspnetcore;asp-net-core;web;minimal-api;openapi;api;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 @@ + From bd6960cc47f4cd1005ad63e1164fa56de50f1945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Mon, 5 Oct 2026 11:48:52 +0300 Subject: [PATCH 3/5] Sync Ulid's PACKAGE.md with updated README information --- src/Ulid/PACKAGE.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 From 72d9803d0eb61d888de9a045c825a9a861e4efd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Mon, 5 Oct 2026 12:01:46 +0300 Subject: [PATCH 4/5] Update README with ASP.NET Core integration details and new logo asset --- README.md | 39 ++++++++++++++++++++++++++++++-- assets/logo_ulid_aspnetcore.png | Bin 0 -> 36480 bytes 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 assets/logo_ulid_aspnetcore.png diff --git a/README.md b/README.md index 82cc096..4775886 100644 --- a/README.md +++ b/README.md @@ -313,9 +313,44 @@ Custom `IRandomProvider` implementations can also be created. ## 🔌 Integration with Other Libraries -### ASP.NET Core +### ASP.NET Core Integration – ByteAether.Ulid.AspNetCore -The core library supports ULIDs as route and query parameters via its built-in `TypeConverter`, and uses `System.Text.Json` strings for request and response payloads. The [ASP.NET Core companion package](./src/AspNetCore/PACKAGE.md) adds a `:ulid` route constraint, typed route link generation, and optional OpenAPI schema support. +[](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 0000000000000000000000000000000000000000..8b920bc40dc34fd264e6eedc61a8d2b4351b564c GIT binary patch literal 36480 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 KD^;-!`Tqd?me$1p literal 0 HcmV?d00001 From 0f4c4b88d54a3e02ebfa29e7cee428d98c3270ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Mon, 5 Oct 2026 12:28:24 +0300 Subject: [PATCH 5/5] Update AspNetCore.csproj with refined metadata and new logo asset --- src/AspNetCore/AspNetCore.Impl.csproj | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/AspNetCore/AspNetCore.Impl.csproj b/src/AspNetCore/AspNetCore.Impl.csproj index f79f5a8..ba9f2e4 100644 --- a/src/AspNetCore/AspNetCore.Impl.csproj +++ b/src/AspNetCore/AspNetCore.Impl.csproj @@ -9,11 +9,12 @@ true true - ByteAether.Ulid.AspNetCore - ASP.NET Core Integration for ULID - Official ASP.NET Core integration for ByteAether.Ulid. Provides a ULID route constraint and optional OpenAPI schema integration while supporting built-in request and response serialization and model binding. + 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 - ulid;aspnetcore;asp-net-core;web;minimal-api;openapi;api;csharp + 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 @@ -36,6 +37,7 @@ + True \