Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
647c5dd
chore: add AI plan for OpenAPI support
feO2x Apr 18, 2026
f553d9c
feat: extend OpenAPI support
feO2x Apr 18, 2026
cf4940b
chore: add OpenAPI support for NativeAotMovieRating project
feO2x Apr 18, 2026
0d36c97
test: increase coverage for OpenAPI types
feO2x Apr 18, 2026
c0b43ae
test: fix failing test
feO2x Apr 18, 2026
112253b
chore: redirect to Scalar when home rout is accessed in NativeAotMovi…
feO2x Apr 19, 2026
49bb5a9
feat: add initial OpenAPI extension to the codebase
feO2x Apr 19, 2026
d08c22e
chore: rename initial OpenAPI Support plan
feO2x Apr 19, 2026
19c96ab
chore: add OpenAPI support redesign plan
feO2x Apr 19, 2026
454c2e4
feat: initial redesign of OpenAPI support
feO2x Apr 20, 2026
99b02ec
chore: add NewMovie endpoint to the project
feO2x Apr 22, 2026
dbad3f9
chore: renamed AddMovieRating to NewMovieRating
feO2x Apr 22, 2026
d7042ec
chore: update to .NET SDK 10.0.202
feO2x Apr 22, 2026
d042fb8
chore: remove native AOT dependencies from NativeAotMovieRating's pac…
feO2x Apr 22, 2026
f2850ed
chore: turn RestoreLockedMode off for NativeAotMovieRating project
feO2x Apr 22, 2026
d7efd05
chore: update .NET SDK version to 10.0.203
feO2x Apr 27, 2026
3af47a7
chore: move PortableOpenApiErrorResponseAttributeBase to own file
feO2x Apr 28, 2026
94444a1
chore: cleanup PortableResultsOpenApiModule
feO2x Apr 28, 2026
41abc86
chore: OpenAPI attributes are no longer sealed
feO2x Apr 28, 2026
887738f
chore: OpenAPI core functionality is no longer internal
feO2x Apr 28, 2026
33f7157
chore: add explaining comments to PortableResultsOpenApiDocumentTrans…
feO2x Apr 28, 2026
08f4ac6
refactor: introduce PortableOpenApiSuccessResponseAttributeBase to re…
feO2x Apr 29, 2026
c1a0a7a
chore: add addtional comments to PortableResultsOpenApiDocumentTransf…
feO2x Apr 29, 2026
c5c91af
chore: use TryAdd in AddComponentAndCreateReference
feO2x Apr 29, 2026
59d3825
chore: remove unused parameter from AddIfMissing method in PortableRe…
feO2x Apr 29, 2026
dd9254a
chore: inject PortableResultsHttpWriteOptions directly into PortableR…
feO2x Apr 29, 2026
49b37c2
fix: ValidateInlineMetadataArrays now throws when one of the arrays i…
feO2x Apr 29, 2026
4d9298c
chore: remove dead EscapeJsonPointer code
feO2x Apr 29, 2026
de6c67c
fix: metadata can now be null
feO2x Apr 29, 2026
cb78176
chore: SanitizeSegment is now private
feO2x Apr 29, 2026
cfff5d8
fix: ResponseGroupKey is now case-insensitive
feO2x Apr 29, 2026
72647e1
fix: OpenAPI GetOrCreateResponse now can handle referenced responses
feO2x Apr 29, 2026
e10d64f
chore: document that PortableResults OpenAPI metadata is authoritativ…
feO2x Apr 29, 2026
9b2482f
chore: RegisterErrorMetadataContractRegistry only once
feO2x Apr 29, 2026
d4512ac
chore: PortableOpenApiSuccessResponseAttributeBase now indicates why …
feO2x Apr 29, 2026
7a0af3b
chore: use HttpMethod.Parse in TryGetOperation
feO2x Apr 29, 2026
afb8543
refactor: introduce subnamespaces in OpenAPI project
feO2x Apr 29, 2026
3d241ae
chore: adjust AI plan 0040-2 to use new OpenAPI namespaces
feO2x Apr 29, 2026
4c090dd
chore: update plan 0040-2 so that it respects recent developments
feO2x Apr 29, 2026
cdb2fa4
feat: implement built-in OpenAPI error contracts
feO2x Apr 29, 2026
722a40c
chore: BuiltInValidationErrorContracts now uses FrozenDictionary
feO2x Apr 29, 2026
534bb44
chore: introduce diagnostic name for schema-based error metadata cont…
feO2x Apr 29, 2026
800fbd4
chore: add 0040-3 plan for improved OpenAPI test coverage
feO2x Apr 29, 2026
ffa806c
test: increase code coverage for OpenAPI functionality
feO2x Apr 29, 2026
9f866ca
chore: simplify name to PortableNoMetadataContract
feO2x Apr 29, 2026
fa8a1d9
chore: remove PortableErrorMetadataContractEqualityComparer and imple…
feO2x Apr 29, 2026
3b27427
test: improve coverage of OpenAPI transformer
feO2x Apr 29, 2026
01af388
chore: increase code coverage for PortableErrorMetadataContract.GetHa…
feO2x Apr 30, 2026
2a4a0d5
refactor: IPortableErrorMetadataContractRegistry now uses a FrozenDic…
feO2x Apr 30, 2026
fb3c8db
refactor: remove ConfigureErrorMetadataContracts and include registra…
feO2x Apr 30, 2026
885bb37
fix: Replace typed NativeAOT-incompatible OpenAPI metadata with schem…
feO2x Apr 30, 2026
7411ee1
chore: rename to ErrorMetadataContract
feO2x Apr 30, 2026
9fabd9a
chore: fix formatting when registering endpoints
feO2x Apr 30, 2026
fdc277a
chore: introduce Swashbuckle UI in NativeAotMovieRating.csproj
feO2x Apr 30, 2026
f6f9518
chore: add plan 0040-5 for exhaustive exhaustive error-code schemas
feO2x Apr 30, 2026
b2922a7
feat: introduce exhaustive OpenAPI error code schemas
feO2x Apr 30, 2026
133e0cb
test: aspNetCoreRequired is now mutated
feO2x Apr 30, 2026
cece303
chore: add plan deviations for 40 OpenAPI support
feO2x Apr 30, 2026
02f6af7
chore: fix requests.http routes
feO2x May 1, 2026
b2f8113
fix: restructure DiagnosticName to SchemaId in ErrorMetadataSchemaCon…
feO2x May 1, 2026
a9144db
fix: WithErrorMetadata on OpenAPI builders now perform NullOrWhiteSpa…
feO2x May 1, 2026
59e1781
chore: fix return type in NewMovie endpoint in NativeAotMovieRating s…
feO2x May 1, 2026
aae55e7
chore: use correct method name in NewMovieEndpoint
feO2x May 1, 2026
e27dd79
chore: ignore .codex files
feO2x May 1, 2026
f003689
chore: GetMoviesEndpoint now uses validation for MovieNotFound
feO2x May 1, 2026
85c4755
chore: GetRangeAfterMovieId now only returns null when the ID could n…
feO2x May 1, 2026
7ee385b
fix: InMemoryMovieDatabase now produces unique IDs
feO2x May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ obj/
*.cobertura.xml
*.received.*
.DS_Store
*.lscache
*.lscache
.codex
5 changes: 4 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Light.SharedCore" Version="3.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
Expand All @@ -17,7 +18,9 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.201" />
<PackageVersion Include="Polyfill" Version="10.3.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="Ulid" Version="1.4.1" />
Expand All @@ -27,4 +30,4 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
</Project>
</Project>
11 changes: 11 additions & 0 deletions Light.PortableResults.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
<File Path="ai-plans\0034-configuration-validation.md" />
<File Path="ai-plans\0034-plan-deviations.md" />
<File Path="ai-plans\0037-validate-items-null-checks.md" />
<File Path="ai-plans\0040-0-openapi-support.md" />
<File Path="ai-plans\0040-1-openapi-redesign.md" />
<File Path="ai-plans\0040-2-validation-error-contracts.md" />
<File Path="ai-plans\0040-3-openapi-test-coverage.md" />
<File Path="ai-plans\0040-4-native-aot-compatibility-for-built-in-validation-contracts.md" />
<File Path="ai-plans\0040-5-openapi-exhaustive.md" />
<File Path="ai-plans\0040-6-plan-deviations.md" />
<File Path="ai-plans\AGENTS.md" />
</Folder>
<Folder Name="/benchmarks/">
Expand All @@ -75,12 +82,16 @@
<Project Path="src\Light.PortableResults.AspNetCore.Mvc\Light.PortableResults.AspNetCore.Mvc.csproj" />
<Project Path="src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj" />
<Project Path="src\Light.PortableResults.AspNetCore.MinimalApis\Light.PortableResults.AspNetCore.MinimalApis.csproj" />
<Project Path="src\Light.PortableResults.AspNetCore.OpenApi\Light.PortableResults.AspNetCore.OpenApi.csproj" />
<Project Path="src\Light.PortableResults.AspNetCore.Shared\Light.PortableResults.AspNetCore.Shared.csproj" />
<Project Path="src\Light.PortableResults\Light.PortableResults.csproj" />
<Project Path="src\Light.PortableResults.Validation.OpenApi\Light.PortableResults.Validation.OpenApi.csproj" />
</Folder>
<Folder Name="/tests/">
<File Path="tests\AGENTS.md" />
<Project Path="tests\Light.PortableResults.AspNetCore.Mvc.Tests\Light.PortableResults.AspNetCore.Mvc.Tests.csproj" />
<Project Path="tests\Light.PortableResults.AspNetCore.OpenApi.Tests\Light.PortableResults.AspNetCore.OpenApi.Tests.csproj" />
<Project Path="tests\Light.PortableResults.Validation.OpenApi.Tests\Light.PortableResults.Validation.OpenApi.Tests.csproj" />
<Project Path="tests/Light.PortableResults.Validation.Tests/Light.PortableResults.Validation.Tests.csproj" />
<Project Path="tests\Light.PortableResults.AspNetCore.MinimalApis.Tests\Light.PortableResults.AspNetCore.MinimalApis.Tests.csproj" />
<Project Path="tests\Light.PortableResults.AspNetCore.Shared.Tests\Light.PortableResults.AspNetCore.Shared.Tests.csproj" />
Expand Down
196 changes: 194 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ ASP.NET Core MVC integration with support for Dependency Injection and `IActionR
dotnet add package Light.PortableResults.AspNetCore.Mvc
```

OpenAPI integration:

```bash
dotnet add package Light.PortableResults.AspNetCore.OpenApi
```

Built-in validation error contracts for OpenAPI:

```bash
dotnet add package Light.PortableResults.Validation.OpenApi
```

If you only need the Result Pattern itself, `Light.PortableResults` is the most lightweight dependency.

## 🤓 Basic Usage
Expand Down Expand Up @@ -359,7 +371,7 @@ Content-Type: application/problem+json
"errors": [
{
"message": "comment must be between 10 and 1000 characters long",
"code": "LengthIn",
"code": "LengthInRange",
"target": "comment",
"category": "Validation",
"metadata": {
Expand All @@ -375,7 +387,7 @@ Content-Type: application/problem+json
},
{
"message": "rating must be between 1 and 5",
"code": "IsInBetween",
"code": "InRange",
"target": "rating",
"category": "Validation",
"metadata": {
Expand Down Expand Up @@ -1322,6 +1334,186 @@ services

`ValidateWithPortableResults` integrates with the standard options validation pipeline, supports named options, and forwards the current options name to the `ValidationContext`. Use `ValidationContext.TryGetItem(ConfigurationConstants.OptionsNameKey, out var optionsName);` to access the options name in your validator.

## OpenAPI Support

OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult<T>` / `LightActionResult<T>` still serialize through the JSON writers in `Light.PortableResults`; the OpenAPI package only contributes endpoint metadata plus a document transformer. If you use `Light.PortableResults.Validation`, add `Light.PortableResults.Validation.OpenApi` to opt into the library-owned built-in validation error contracts.

### Registration

```csharp
using Light.PortableResults.AspNetCore.MinimalApis;
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Validation.OpenApi;

builder.Services
.AddOpenApi()
.AddPortableResultsForMinimalApis()
.AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors());
```

Use `AddPortableResultsForMvc()` instead of `AddPortableResultsForMinimalApis()` for MVC applications. OpenAPI support is intentionally separate so applications that never generate OpenAPI documents do not take on the extra dependency.

### Public surface

Minimal APIs expose three helpers in `Light.PortableResults.AspNetCore.OpenApi`:

- `ProducesPortableSuccessResponse<TValue>(...)`
- `ProducesPortableProblem(...)`
- `ProducesPortableValidationProblem(...)`

MVC exposes three matching attributes:

- `[ProducesPortableSuccessResponse<TValue>]`
- `[ProducesPortableProblem]`
- `[ProducesPortableValidationProblem]`

`ProducesPortableSuccessResponse<TValue>` documents both runtime success shapes:

- Under `MetadataSerializationMode.ErrorsOnly`, the documented body is the bare `TValue`.
- Under `MetadataSerializationMode.Always`, the documented body is `{ value, metadata }`.

Use `UseMetadataSerializationMode(...)` on Minimal APIs or the `MetadataSerializationMode = ...` named argument on the MVC attribute when the endpoint’s documented shape differs from the DI default.

`ProducesPortableValidationProblem(...)` automatically selects the rich or ASP.NET Core-compatible validation envelope from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat`. Use `UseFormat(...)` on Minimal APIs or `Format = ...` on the MVC attribute for a per-endpoint override.

PortableResults OpenAPI metadata is authoritative for a given `(status code, content type)` response slot. If another OpenAPI contributor already documented the same slot, the document transformer replaces that media-type schema instead of merging it. Avoid combining `ProducesPortable...` helpers or attributes with ASP.NET Core response-schema helpers for the same response slot unless you want PortableResults to win.

### Documenting metadata

Top-level metadata and per-error-code metadata are caller-owned contracts. The OpenAPI package documents them explicitly; the runtime still writes `MetadataObject`.

`WithErrorCodes(...)`, endpoint-scoped `WithErrorMetadata<TMetadata>(code)`, and the typed validation helpers such as `WithInRangeError<T>()` narrow error items exhaustively by default once you document at least one code. The generated item schema becomes a discriminated `oneOf` over the documented variants with `code` required, so you are asserting that every emitted error item has a non-null `code` and that the code is in the documented set.

If an endpoint can still emit additional codes outside the documented set, opt out explicitly with `AllowUnknownErrorCodes()` on the Minimal API builders or `AllowUnknownErrorCodes = true` on the MVC attributes. In that mode the generated schema falls back to the non-exhaustive `anyOf` shape with the canonical `PortableError` / `PortableValidationErrorDetail` branch preserved for unknown codes.

`AllowUnknownErrorCodes()` does not relax the `code` requirement on narrowed item schemas. If an endpoint can emit code-less errors, do not narrow that endpoint's error items in the first place; use the canonical envelope schema instead.

When top-level metadata or documented error items are narrowed, the generated response envelope is a flattened concrete object schema that copies the canonical problem-details properties and overrides only `errors` / `errorDetails` / `metadata`. This improves Swagger UI and code-generator output without changing the runtime wire format.

For built-in validation errors, reference `Light.PortableResults.Validation.OpenApi` and pass the catalog registration to `AddPortableResultsOpenApi(...)` once:

```csharp
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Validation.OpenApi;

builder.Services.AddPortableResultsOpenApi(
contracts => contracts.RegisterBuiltInValidationErrors()
);
```

Use `ValidationErrorCodes` when opting endpoints into built-in codes. Codes such as `NotEmpty`, `LengthInRange`, and `Count` reuse global schemas from the built-in catalog:

```csharp
using Light.PortableResults.Validation;
using Light.PortableResults.Validation.OpenApi;

app.MapPut("/api/movieRatings", AddMovieRating)
.ProducesPortableValidationProblem(
configure: x =>
x.UseFormat(ValidationProblemSerializationFormat.Rich)
.WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange)
.WithInRangeError<int>()
);
```

Use `AllowUnknownErrorCodes()` when the endpoint may emit additional documented-shape errors outside the documented code set, for example when built-in validation codes are documented but a downstream lookup may still add a custom code:

```csharp
app.MapGet("/api/movies", GetMovies)
.ProducesPortableValidationProblem(
configure: x =>
x.UseFormat(ValidationProblemSerializationFormat.Rich)
.WithErrorCodes(ValidationErrorCodes.NotEmpty)
.WithInRangeError<int>()
.AllowUnknownErrorCodes()
);
```

Comparison and range codes are polymorphic at the global code level, so the validation bridge also ships typed endpoint helpers: `WithEqualToError<T>()`, `WithNotEqualToError<T>()`, `WithGreaterThanError<T>()`, `WithGreaterThanOrEqualToError<T>()`, `WithLessThanError<T>()`, `WithLessThanOrEqualToError<T>()`, `WithInRangeError<T>()`, `WithNotInRangeError<T>()`, and `WithExclusiveRangeError<T>()`. These helpers use the existing inline metadata path, so an endpoint can document concrete metadata such as integer range boundaries for `IsInBetween(1, 5)` while still reusing global schemas for shape-fixed codes.

Register reusable per-error-code metadata contracts once in DI by passing them to `AddPortableResultsOpenApi(...)`:

```csharp
using Light.PortableResults.AspNetCore.OpenApi;

builder.Services.AddPortableResultsOpenApi(contracts =>
{
contracts.ForCode<VersionMismatchMetadata>("VersionMismatch");
contracts.ForCode<InsufficientFundsMetadata>("InsufficientFunds");
});
```

User-defined codes continue to use the type-based overloads above, or endpoint-scoped `WithErrorMetadata<TMetadata>(code)` when a contract only applies to one operation.

Then opt the relevant codes into each endpoint:

```csharp
using Light.PortableResults;
using Light.PortableResults.AspNetCore.MinimalApis;
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Http.Writing;

app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) =>
{
var result = await service.AddMovieRatingAsync(dto);
return result.ToMinimalApiResult();
})
.ProducesPortableSuccessResponse<MovieRating>(
configure: x =>
x.WithMetadata<MovieRatingResponseMetadata>()
.UseMetadataSerializationMode(MetadataSerializationMode.Always)
)
.ProducesPortableValidationProblem(
configure: x =>
x.UseFormat(ValidationProblemSerializationFormat.Rich)
.WithErrorCodes("VersionMismatch")
)
.ProducesPortableProblem(
statusCode: StatusCodes.Status404NotFound,
configure: x =>
x.WithMetadata<MovieProblemMetadata>()
.WithErrorMetadata<MovieNotFoundMetadata>("MovieNotFound")
)
.ProducesPortableProblem();
```

The MVC equivalent uses named attribute arguments:

```csharp
using Light.PortableResults;
using Light.PortableResults.AspNetCore.Mvc;
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Http.Writing;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/movieRatings")]
public sealed class AddMovieRatingsController(AddMovieRatingService service) : ControllerBase
{
[HttpPut]
[ProducesPortableSuccessResponse<MovieRating>(
TopLevelMetadataType = typeof(MovieRatingResponseMetadata),
MetadataSerializationMode = MetadataSerializationMode.Always
)]
[ProducesPortableValidationProblem(
Format = ValidationProblemSerializationFormat.Rich,
ErrorCodes = new[] { "VersionMismatch" }
)]
[ProducesPortableProblem(
statusCode: StatusCodes.Status404NotFound,
TopLevelMetadataType = typeof(MovieProblemMetadata),
InlineErrorMetadataCodes = new[] { "MovieNotFound" },
InlineErrorMetadataContracts = new[] { ErrorMetadataContract.FromType(typeof(MovieNotFoundMetadata)) }
)]
[ProducesPortableProblem]
public async Task<LightActionResult<MovieRating>> AddMovieRating(AddMovieRatingDto dto)
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MVC example references AddMovieRatingDto, but there is no such type in the sample code (the sample uses MovieRatingDto). This makes the documentation example inconsistent/non-compilable; update the parameter type to the correct DTO used by the library/sample.

Suggested change
public async Task<LightActionResult<MovieRating>> AddMovieRating(AddMovieRatingDto dto)
public async Task<LightActionResult<MovieRating>> AddMovieRating(MovieRatingDto dto)

Copilot uses AI. Check for mistakes.
{
var result = await service.AddMovieRatingAsync(dto);
return result.ToMvcActionResult();
}
}
```

## ⚙️ Configuration for HTTP and CloudEvents

### HTTP write options (`PortableResultsHttpWriteOptions`)
Expand Down
Loading
Loading