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/)
+
+[](https://github.com/ByteAether/Ulid/blob/main/LICENSE)
+
+[](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/)
+[](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*
+
+[](https://github.com/ByteAether/Ulid/blob/main/LICENSE)
+
+
+
+
+
+[](https://www.nuget.org/packages/ByteAether.Ulid.AspNetCore/)
+[](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