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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/build-and-test-aot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`.
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`.
[<img align="right" width="100px" src="assets/logo_ulid_aspnetcore.png" />](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+)

Expand Down
Binary file added assets/logo_ulid_aspnetcore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<SupportedImplFrameworks>net10.0;net9.0;net8.0;net7.0;net6.0</SupportedImplFrameworks>

<IsTestProject Condition="$(SupportedImplFrameworks.Contains('$(ImplTargetFramework)'))">true</IsTestProject>
<RootNamespace>ByteAether.Ulid.AspNetCore.IntegrationTests.OpenApi</RootNamespace>
<DefineConstants Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(ImplTargetFramework)', 'net9.0'))">$(DefineConstants);IMPL_NET9_OR_GREATER</DefineConstants>
<InterceptorsNamespaces Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(ImplTargetFramework)', 'net9.0'))">$(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated</InterceptorsNamespaces>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.OpenApi" VersionOverride="1.2.3" Condition="'$(ImplTargetFramework)' != 'net9.0' and '$(ImplTargetFramework)' != 'net10.0'" />
<PackageReference Include="Microsoft.OpenApi" VersionOverride="1.6.17" Condition="'$(ImplTargetFramework)' == 'net9.0'" />
<PackageReference Include="Microsoft.OpenApi" VersionOverride="2.7.5" Condition="'$(ImplTargetFramework)' == 'net10.0'" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" VersionOverride="9.0.0" Condition="'$(ImplTargetFramework)' == 'net9.0'" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" VersionOverride="10.0.0" Condition="'$(ImplTargetFramework)' == 'net10.0'" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" VersionOverride="6.0.0" Condition="'$(ImplTargetFramework)' != 'net10.0'" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" VersionOverride="10.0.0" Condition="'$(ImplTargetFramework)' == 'net10.0'" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AspNetCore\AspNetCore.Impl.csproj" SetTargetFramework="TargetFramework=$(ImplTargetFramework)" />
</ItemGroup>
</Project>
48 changes: 48 additions & 0 deletions src/AspNetCore.IntegrationTests.OpenApi/OpenApiSchemaAssertions.cs
Original file line number Diff line number Diff line change
@@ -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<JsonElement>();
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<JsonElement> 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);
}
}
}
}
57 changes: 57 additions & 0 deletions src/AspNetCore.IntegrationTests.OpenApi/OpenApiTests.cs
Original file line number Diff line number Diff line change
@@ -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<UlidResponse>(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
Original file line number Diff line number Diff line change
@@ -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<UlidSchemaFilter>());

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);
}
}
19 changes: 19 additions & 0 deletions src/AspNetCore.IntegrationTests/AspNetCore.IntegrationTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<SupportedImplFrameworks>net10.0;net9.0;net8.0;net7.0;net6.0</SupportedImplFrameworks>

<IsTestProject Condition="$(SupportedImplFrameworks.Contains('$(ImplTargetFramework)'))">true</IsTestProject>
<RootNamespace>ByteAether.Ulid.AspNetCore.IntegrationTests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AspNetCore\AspNetCore.Impl.csproj" SetTargetFramework="TargetFramework=$(ImplTargetFramework)" />
</ItemGroup>

</Project>
Loading
Loading