From 647c5dd621d1e27725516019b4519b012e84ed52 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 18 Apr 2026 13:31:05 +0200 Subject: [PATCH 01/67] chore: add AI plan for OpenAPI support Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0040-openapi-support.md | 156 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 ai-plans/0040-openapi-support.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index ea8edb3..6d0c30d 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -49,6 +49,7 @@ + diff --git a/ai-plans/0040-openapi-support.md b/ai-plans/0040-openapi-support.md new file mode 100644 index 0000000..e74ae07 --- /dev/null +++ b/ai-plans/0040-openapi-support.md @@ -0,0 +1,156 @@ +# OpenAPI Support for Portable Problem Details + +## Rationale + +Light.PortableResults can already serialize failure responses as RFC 9457 Problem Details over HTTP, but there is no corresponding way to document those failure shapes for OpenAPI. Callers building Minimal APIs or MVC endpoints on top of `ToMinimalApiResult` and `ToMvcActionResult` have no library-provided helpers to tell OpenAPI generators which problem schema an endpoint produces for validation errors or for other failure codes such as 401, 404, or 409. + +This plan closes that gap by adding schema-only CLR types and explicit endpoint metadata helpers for failure responses. The implementation stays intentionally static: OpenAPI generators need only a CLR type to infer a schema, while the runtime HTTP writers continue to serialize failures dynamically from `Errors`, `ProblemDetailsInfo`, and `PortableResultsHttpWriteOptions`. No automatic OpenAPI inference, runtime schema transformers, or dedicated OpenAPI integration package is introduced. + +The plan also revisits the success-side OpenAPI naming and usage model. A successful `Result` has only two meaningful body contracts: either the body is just `TValue`, or the body is `{ value, metadata }`. If the body is just `TValue`, callers should use the normal ASP.NET Core OpenAPI metadata helpers such as `Produces(...)` or `ProducesResponseTypeAttribute`. If the body includes `metadata`, then the metadata type is part of the contract and must be explicit. Because of that, the Light.PortableResults-specific success-side OpenAPI surface should only exist for the wrapped `{ value, metadata }` case and must always require `TMetadata`. + +Introducing the failure-side types also creates an opportunity to establish a consistent naming scheme across the whole OpenAPI surface. The existing success-side types (`WrappedResponse`, `ProducesPortableResult(...)`, `ProducesPortableResultAttribute<...>`) carry generic names that do not clearly identify them as schema-only OpenAPI helpers. This plan renames them to `PortableSuccessResponse`, `ProducesPortableSuccessResponse`, and `ProducesPortableSuccessResponseAttribute`. Since the library is still pre-1.0, the breaking rename is acceptable now. + +## Acceptance Criteria + +- [ ] `WrappedResponse` is renamed to `PortableSuccessResponse`, the existing success-side helpers are renamed to `ProducesPortableSuccessResponse` and `ProducesPortableSuccessResponseAttribute`, and the single-generic success-side helpers are removed. After this change, `WrappedResponse`, `ProducesPortableResult`, `ProducesPortableResultAttribute`, and any other success-side `object`-based OpenAPI convenience surface no longer exist anywhere in the codebase. +- [ ] The success-side OpenAPI model is explicit: callers who serialize only `TValue` in successful responses use the standard ASP.NET Core OpenAPI metadata APIs, while callers who serialize `{ value, metadata }` use the Light.PortableResults-specific success helper with an explicit `TMetadata`. +- [ ] `Light.PortableResults.AspNetCore.Shared` contains the exact schema-only OpenAPI types defined in this plan: `PortableSuccessResponse`, `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`. +- [ ] `Light.PortableResults.AspNetCore.MinimalApis` contains the exact `RouteHandlerBuilder` extension method overloads defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier overloads for success or failure responses. +- [ ] `Light.PortableResults.AspNetCore.Mvc` contains the exact response metadata attributes defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier attributes for success or failure responses. +- [ ] The generic parameter meanings are fixed as follows throughout the public API: `TMetadata` on `PortableSuccessResponse` is success-body metadata, `TErrorMetadata` is metadata on each rich error item, `TErrorDetailMetadata` is metadata on each ASP.NET Core-compatible `errorDetails` item, and `TProblemMetadata` is top-level problem metadata. +- [ ] Rich problem helpers and ASP.NET Core-compatible validation problem helpers are separate public APIs so callers must choose the schema that matches their configured HTTP serialization format. +- [ ] The implementation does not change the runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, or the JSON writers in `Light.PortableResults`. +- [ ] Automated tests are written for the renamed success helpers and for all new Minimal API and MVC OpenAPI metadata helpers. The MVC test project gains a new unit test class for attribute metadata. +- [ ] `README.md` documents the breaking rename, the new OpenAPI support for error responses, and the success-side rule that plain `TValue` success responses use standard ASP.NET Core OpenAPI helpers while `{ value, metadata }` success responses use `ProducesPortableSuccessResponse` or `ProducesPortableSuccessResponseAttribute`. + +## Technical Details + +### Public Schema Types + +Add the following schema-only types to `Light.PortableResults.AspNetCore.Shared`. These types exist only for OpenAPI schema generation and are not used by the runtime HTTP writing pipeline. + +All schema-only model types in this plan should be public and not sealed. They are OpenAPI contract types, are not instantiated by the library at runtime, and may be useful to callers as reusable contract models. + +The failure-side non-generic and two-generic variants form a two-tier hierarchy. Convenience non-generic subtypes inherit from the two-generic base with `object` substituted for both type parameters. The two-generic failure-side base types must therefore not be sealed. `PortableSuccessResponse` does not need a non-generic or single-generic subtype because the success-side OpenAPI surface should only be used when the metadata type is explicit. + +`PortableSuccessResponse` is only used to document wrapped success bodies that contain both `value` and `metadata`. Plain `TValue` success bodies are documented with the standard ASP.NET Core OpenAPI metadata APIs. + +**Success response:** +- `PortableSuccessResponse` with the properties `TValue Value` and `TMetadata Metadata`. + +**Error items:** +- `PortableError` with the properties `string Message`, `string? Code`, `string? Target`, `ErrorCategory Category`, and `object? Metadata`. +- `PortableError` with the properties `string Message`, `string? Code`, `string? Target`, `ErrorCategory Category`, and `TMetadata Metadata`. +- `PortableValidationErrorDetail` with the properties `string Target`, `int Index`, `string? Code`, `ErrorCategory? Category`, and `object? Metadata`. The `Index` property is the zero-based position of the corresponding error message within the `errors[target]` array for the same target. +- `PortableValidationErrorDetail` with the properties `string Target`, `int Index`, `string? Code`, `ErrorCategory? Category`, and `TMetadata Metadata`. + +**Problem details (generic + non-generic):** +- `PortableProblemDetails` deriving from `ProblemDetails` with the properties `IReadOnlyList> Errors` and `TProblemMetadata Metadata`. +- `PortableProblemDetails` inheriting from `PortableProblemDetails`. +- `PortableRichValidationProblemDetails` deriving from `ProblemDetails` with the properties `IReadOnlyList> Errors` and `TProblemMetadata Metadata`. +- `PortableRichValidationProblemDetails` inheriting from `PortableRichValidationProblemDetails`. +- `PortableAspNetCoreValidationProblemDetails` deriving from `HttpValidationProblemDetails` with the properties `IReadOnlyList>? ErrorDetails` and `TProblemMetadata Metadata`. +- `PortableAspNetCoreValidationProblemDetails` inheriting from `PortableAspNetCoreValidationProblemDetails`. + +`PortableRichValidationProblemDetails` is structurally identical to `PortableProblemDetails`, but the separation is intentional: keeping them as distinct CLR types causes OpenAPI generators to emit different schema definitions with meaningful names for general errors and validation errors. Do not collapse the two families into one type. + +The success-response rename should be applied consistently across the code base, tests, XML documentation, and README. + +### Minimal API Helpers + +Rename the existing success helper in `PortableResultsEndpointExtensions` to the following exact signature: + +- `public static RouteHandlerBuilder ProducesPortableSuccessResponse(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, string contentType = "application/json")` + +Remove the single-generic success helper. Callers who do not serialize metadata in the success response body should use the standard ASP.NET Core OpenAPI metadata APIs such as `Produces(...)`. Callers who do serialize metadata in the body should use `ProducesPortableSuccessResponse(...)`. + +Add the following exact failure-response helper signatures to `PortableResultsEndpointExtensions`: + +- `public static RouteHandlerBuilder ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json")` +- `public static RouteHandlerBuilder ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json")` +- `public static RouteHandlerBuilder ProducesPortableRichValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")` +- `public static RouteHandlerBuilder ProducesPortableRichValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")` +- `public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")` +- `public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")` + +The non-generic overloads point to `PortableProblemDetails`, `PortableRichValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`. The two-generic overloads point to the corresponding `` or `` types. There are no one-generic middle-tier overloads; callers who want only typed problem metadata use the two-generic overload with `object` as the first type argument. + +The validation helpers default to `400 Bad Request` because that is the default validation response in the library today, but the `statusCode` parameter must also allow callers to document `422 Unprocessable Content` when they intentionally expose that contract. + +`ProducesPortableProblem` is also the correct helper for non-validation 4xx responses such as `401 Unauthorized`, `403 Forbidden`, and `404 Not Found`; callers simply pass the relevant status code. There are no dedicated per-status-code helpers for these cases. + +Keep the API explicit rather than accepting `ValidationProblemSerializationFormat` as a method parameter. OpenAPI must point to one concrete schema, and explicit helper names make it much harder for callers to document the wrong shape. + +### MVC Attributes + +Rename the existing success attribute to the following exact name and type shape: + +- `public sealed class ProducesPortableSuccessResponseAttribute : ProducesResponseTypeAttribute>` + +The success attribute should keep the constructor signature `public ... (int statusCode = StatusCodes.Status200OK, string contentType = "application/json")`. + +Remove the single-generic success attribute. Callers who do not serialize metadata in the success response body should use the standard ASP.NET Core response metadata attributes such as `ProducesResponseTypeAttribute`. Callers who do serialize metadata in the body should use `ProducesPortableSuccessResponseAttribute`. + +Add the following exact failure-response attribute families: + +- `ProducesPortableProblemAttribute` +- `ProducesPortableProblemAttribute` +- `ProducesPortableRichValidationProblemAttribute` +- `ProducesPortableRichValidationProblemAttribute` +- `ProducesPortableAspNetCoreValidationProblemAttribute` +- `ProducesPortableAspNetCoreValidationProblemAttribute` + +Each attribute should derive from `ProducesResponseTypeAttribute` with the corresponding schema-only type from `Light.PortableResults.AspNetCore.Shared`. There are no one-generic middle-tier attributes. + +The exact constructor signatures should be: + +- problem attributes: `public ... (int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json")` +- rich validation attributes: `public ... (int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")` +- ASP.NET Core-compatible validation attributes: `public ... (int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")` + +As with the Minimal API helpers, the validation attributes must allow callers to override the status code to `422` when needed. + +### Scope Boundaries + +This feature should remain documentation-only: + +- do not change the runtime JSON serialization code in `Light.PortableResults` +- do not modify `LightResult` or `LightActionResult` to infer response metadata automatically +- do not introduce `Microsoft.AspNetCore.OpenApi` transformers or a separate OpenAPI integration package +- do not attempt to infer validation schema shape from `PortableResultsHttpWriteOptions` + +Instead, the caller explicitly chooses the documented schema that matches the endpoint contract and the configured `ValidationProblemSerializationFormat`. + +### Automated Tests + +Add unit tests in the Minimal API and MVC test projects that verify the exact renamed and newly added helper surfaces from this plan. + +The MinimalApis test project already has `PortableResultsEndpointExtensionsTests` as a template. The MVC test project currently has no unit test class for attribute metadata; add one following the same pattern. + +The test coverage should include: + +- the renamed success-side Minimal API helper registers `PortableSuccessResponse` metadata entries +- the renamed success-side MVC attribute points to `PortableSuccessResponse` +- `ProducesPortableProblem...` and `ProducesPortableProblemAttribute...` register the correct `PortableProblemDetails...` schema types +- `ProducesPortableRichValidationProblem...` and `ProducesPortableRichValidationProblemAttribute...` register the correct `PortableRichValidationProblemDetails...` schema types +- `ProducesPortableAspNetCoreValidationProblem...` and `ProducesPortableAspNetCoreValidationProblemAttribute...` register the correct `PortableAspNetCoreValidationProblemDetails...` schema types +- non-generic and two-generic overloads both point to the expected schema-only CLR types +- the removed single-generic success-side helpers are no longer present +- default content types and default status codes match the signatures defined in this plan + +### README Documentation + +Extend `README.md` in the HTTP / ASP.NET Core section rather than creating a separate documentation chapter. + +The README changes should include: + +- a note about the breaking rename: `WrappedResponse` becomes `PortableSuccessResponse`, `ProducesPortableResult` becomes `ProducesPortableSuccessResponse`, and `ProducesPortableResultAttribute` becomes `ProducesPortableSuccessResponseAttribute` +- a short explanation of the success-side rule: use standard ASP.NET Core OpenAPI helpers when the success body is just `TValue`, and use `ProducesPortableSuccessResponse` or `ProducesPortableSuccessResponseAttribute` only when the success body is `{ value, metadata }` +- two Minimal APIs examples that each combine `ProducesPortableSuccessResponse(...)` with `ProducesPortableProblem(...)` and a validation helper; one example should use `ProducesPortableRichValidationProblem(...)` and the other should use `ProducesPortableAspNetCoreValidationProblem(...)` +- an MVC example that combines `ProducesPortableSuccessResponseAttribute` with one problem attribute and one validation attribute +- a concise explanation of the two validation problem formats: `AspNetCoreCompatible` documents `errors` as `Dictionary` plus an optional `errorDetails` array, while `Rich` documents `errors` as an array of Light.PortableResults-style error objects +- an explanation of the `Index` property on `PortableValidationErrorDetail`: it is the zero-based position of the corresponding error message within the `errors[target]` array for the same target, allowing `errorDetails` entries to be correlated back to the matching message +- a note that callers must choose the OpenAPI helper that matches the actual configured `ValidationProblemSerializationFormat` +- a note that the typed metadata CLR types are schema-only helpers for OpenAPI; the runtime still serializes `MetadataObject`, so callers are responsible for keeping the documented schema aligned with the metadata they actually produce + +Keep the README focused on practical endpoint examples and avoid going deep into internal implementation details. From f553d9c0c2402cad2c7da72e0223b1bf6d2baeb1 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 18 Apr 2026 14:04:17 +0200 Subject: [PATCH 02/67] feat: extend OpenAPI support Signed-off-by: Kenny Pflug --- README.md | 88 +++++++++++ ai-plans/0040-openapi-support.md | 20 +-- .../PortableResultsEndpointExtensions.cs | 139 +++++++++++++++--- ...bleAspNetCoreValidationProblemAttribute.cs | 54 +++++++ .../ProducesPortableProblemAttribute.cs | 44 ++++++ .../ProducesPortableResultAttribute.cs | 45 ------ ...sPortableRichValidationProblemAttribute.cs | 48 ++++++ ...roducesPortableSuccessResponseAttribute.cs | 26 ++++ ...tableAspNetCoreValidationProblemDetails.cs | 39 +++++ .../PortableError.cs | 67 +++++++++ .../PortableProblemDetails.cs | 32 ++++ .../PortableRichValidationProblemDetails.cs | 35 +++++ .../PortableSuccessResponse.cs | 24 +++ .../PortableValidationErrorDetail.cs | 70 +++++++++ .../WrappedResponse.cs | 20 --- .../PortableResultsEndpointExtensionsTests.cs | 129 +++++++++++----- .../IntegrationTests/RegularMvcController.cs | 6 +- .../ProducesPortableAttributesTests.cs | 99 +++++++++++++ 18 files changed, 852 insertions(+), 133 deletions(-) create mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableResultAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableError.cs create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/WrappedResponse.cs create mode 100644 tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs diff --git a/README.md b/README.md index 6bbd182..3b25a03 100644 --- a/README.md +++ b/README.md @@ -1322,6 +1322,94 @@ 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 + +Light.PortableResults ships schema-only CLR types and endpoint metadata helpers so OpenAPI generators can emit accurate response schemas for both success and failure responses. These helpers are documentation-only: the runtime HTTP serialization still happens through `LightResult` / `LightActionResult` and the JSON writers in `Light.PortableResults`. + +### Success responses: when to use which helper + +A successful `Result` serializes to one of two body shapes: + +- If the body is just `TValue`, document it with the standard ASP.NET Core OpenAPI helpers such as `Produces(...)` on Minimal APIs or `ProducesResponseType` on MVC controllers. Do **not** use Light.PortableResults-specific success helpers for this case. +- If the body is `{ value, metadata }` (see `MetadataSerializationMode.Always`), document it with `ProducesPortableSuccessResponse(...)` or `[ProducesPortableSuccessResponse]`. The metadata type is now an explicit part of the contract. + +### Failure responses + +Failure responses are documented with dedicated helpers per problem-details shape: + +- `ProducesPortableProblem` / `ProducesPortableProblemAttribute` for non-validation failures (401, 403, 404, 409, 500, ...). Pass the relevant status code; there are no dedicated per-status-code helpers. +- `ProducesPortableRichValidationProblem` / `ProducesPortableRichValidationProblemAttribute` when `ValidationProblemSerializationFormat` is set to `Rich`. Here the `errors` property is documented as an array of Light.PortableResults-style error objects. +- `ProducesPortableAspNetCoreValidationProblem` / `ProducesPortableAspNetCoreValidationProblemAttribute` when `ValidationProblemSerializationFormat` is set to `AspNetCoreCompatible` (the default). Here the inherited `errors` property is documented as `Dictionary`, and an optional `errorDetails` array carries Light.PortableResults-specific information such as codes, categories, and metadata. + +You must choose the OpenAPI helper that matches the actual configured `ValidationProblemSerializationFormat`; the library does not infer the validation shape for you. + +The `Index` property on `PortableValidationErrorDetail` is the zero-based position of the corresponding error message within the `errors[target]` array for the same target, so `errorDetails` entries can be correlated back to the matching message. + +Each helper family has a non-generic overload and a two-generic overload. The two-generic overloads let you strongly type per-error metadata and top-level problem metadata. There are no one-generic middle-tier overloads; callers who want only typed problem metadata use the two-generic overload with `object` as the first type argument. + +The typed metadata CLR types are schema-only helpers for OpenAPI. The runtime always serializes `MetadataObject`, so you are responsible for keeping the documented schema aligned with the metadata you actually produce. + +### Minimal APIs example (rich validation) + +```csharp +using Light.PortableResults; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.Metadata; + +app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => + { + var result = await service.AddMovieRatingAsync(dto); + return result.ToMinimalApiResult(); + }) + .ProducesPortableSuccessResponse() + .ProducesPortableRichValidationProblem() + .ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound) + .ProducesPortableProblem(); +``` + +### Minimal APIs example (ASP.NET Core-compatible validation) + +```csharp +using Light.PortableResults; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.Metadata; + +app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => + { + var result = await service.AddMovieRatingAsync(dto); + return result.ToMinimalApiResult(); + }) + .ProducesPortableSuccessResponse() + .ProducesPortableAspNetCoreValidationProblem() + .ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound) + .ProducesPortableProblem(); +``` + +### MVC example + +```csharp +using Light.PortableResults; +using Light.PortableResults.AspNetCore.Mvc; +using Light.PortableResults.Metadata; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/movieRatings")] +public sealed class AddMovieRatingsController(AddMovieRatingService service) : ControllerBase +{ + [HttpPut] + [ProducesPortableSuccessResponse] + [ProducesPortableRichValidationProblem] + [ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound)] + [ProducesPortableProblem] + public async Task> AddMovieRating(AddMovieRatingDto dto) + { + var result = await service.AddMovieRatingAsync(dto); + return result.ToMvcActionResult(); + } +} +``` + ## ⚙️ Configuration for HTTP and CloudEvents ### HTTP write options (`PortableResultsHttpWriteOptions`) diff --git a/ai-plans/0040-openapi-support.md b/ai-plans/0040-openapi-support.md index e74ae07..8754f33 100644 --- a/ai-plans/0040-openapi-support.md +++ b/ai-plans/0040-openapi-support.md @@ -12,16 +12,16 @@ Introducing the failure-side types also creates an opportunity to establish a co ## Acceptance Criteria -- [ ] `WrappedResponse` is renamed to `PortableSuccessResponse`, the existing success-side helpers are renamed to `ProducesPortableSuccessResponse` and `ProducesPortableSuccessResponseAttribute`, and the single-generic success-side helpers are removed. After this change, `WrappedResponse`, `ProducesPortableResult`, `ProducesPortableResultAttribute`, and any other success-side `object`-based OpenAPI convenience surface no longer exist anywhere in the codebase. -- [ ] The success-side OpenAPI model is explicit: callers who serialize only `TValue` in successful responses use the standard ASP.NET Core OpenAPI metadata APIs, while callers who serialize `{ value, metadata }` use the Light.PortableResults-specific success helper with an explicit `TMetadata`. -- [ ] `Light.PortableResults.AspNetCore.Shared` contains the exact schema-only OpenAPI types defined in this plan: `PortableSuccessResponse`, `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`. -- [ ] `Light.PortableResults.AspNetCore.MinimalApis` contains the exact `RouteHandlerBuilder` extension method overloads defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier overloads for success or failure responses. -- [ ] `Light.PortableResults.AspNetCore.Mvc` contains the exact response metadata attributes defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier attributes for success or failure responses. -- [ ] The generic parameter meanings are fixed as follows throughout the public API: `TMetadata` on `PortableSuccessResponse` is success-body metadata, `TErrorMetadata` is metadata on each rich error item, `TErrorDetailMetadata` is metadata on each ASP.NET Core-compatible `errorDetails` item, and `TProblemMetadata` is top-level problem metadata. -- [ ] Rich problem helpers and ASP.NET Core-compatible validation problem helpers are separate public APIs so callers must choose the schema that matches their configured HTTP serialization format. -- [ ] The implementation does not change the runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, or the JSON writers in `Light.PortableResults`. -- [ ] Automated tests are written for the renamed success helpers and for all new Minimal API and MVC OpenAPI metadata helpers. The MVC test project gains a new unit test class for attribute metadata. -- [ ] `README.md` documents the breaking rename, the new OpenAPI support for error responses, and the success-side rule that plain `TValue` success responses use standard ASP.NET Core OpenAPI helpers while `{ value, metadata }` success responses use `ProducesPortableSuccessResponse` or `ProducesPortableSuccessResponseAttribute`. +- [x] `WrappedResponse` is renamed to `PortableSuccessResponse`, the existing success-side helpers are renamed to `ProducesPortableSuccessResponse` and `ProducesPortableSuccessResponseAttribute`, and the single-generic success-side helpers are removed. After this change, `WrappedResponse`, `ProducesPortableResult`, `ProducesPortableResultAttribute`, and any other success-side `object`-based OpenAPI convenience surface no longer exist anywhere in the codebase. +- [x] The success-side OpenAPI model is explicit: callers who serialize only `TValue` in successful responses use the standard ASP.NET Core OpenAPI metadata APIs, while callers who serialize `{ value, metadata }` use the Light.PortableResults-specific success helper with an explicit `TMetadata`. +- [x] `Light.PortableResults.AspNetCore.Shared` contains the exact schema-only OpenAPI types defined in this plan: `PortableSuccessResponse`, `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`. +- [x] `Light.PortableResults.AspNetCore.MinimalApis` contains the exact `RouteHandlerBuilder` extension method overloads defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier overloads for success or failure responses. +- [x] `Light.PortableResults.AspNetCore.Mvc` contains the exact response metadata attributes defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier attributes for success or failure responses. +- [x] The generic parameter meanings are fixed as follows throughout the public API: `TMetadata` on `PortableSuccessResponse` is success-body metadata, `TErrorMetadata` is metadata on each rich error item, `TErrorDetailMetadata` is metadata on each ASP.NET Core-compatible `errorDetails` item, and `TProblemMetadata` is top-level problem metadata. +- [x] Rich problem helpers and ASP.NET Core-compatible validation problem helpers are separate public APIs so callers must choose the schema that matches their configured HTTP serialization format. +- [x] The implementation does not change the runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, or the JSON writers in `Light.PortableResults`. +- [x] Automated tests are written for the renamed success helpers and for all new Minimal API and MVC OpenAPI metadata helpers. The MVC test project gains a new unit test class for attribute metadata. +- [x] `README.md` documents the breaking rename, the new OpenAPI support for error responses, and the success-side rule that plain `TValue` success responses use standard ASP.NET Core OpenAPI helpers while `{ value, metadata }` success responses use `ProducesPortableSuccessResponse` or `ProducesPortableSuccessResponseAttribute`. ## Technical Details diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs b/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs index 2cd613c..135a329 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs @@ -5,44 +5,139 @@ namespace Light.PortableResults.AspNetCore.MinimalApis; /// -/// Extension methods for configuring OpenAPI metadata for Light.PortableResults endpoints. +/// Extension methods that add OpenAPI response metadata for Light.PortableResults endpoints. +/// These helpers are documentation-only: they register schema-only CLR types with the endpoint so +/// OpenAPI generators can emit accurate response schemas. The runtime HTTP serialization behavior is +/// unaffected. /// public static class PortableResultsEndpointExtensions { /// - /// Adds OpenAPI response metadata for LightSuccessResult with typed metadata schema. - /// Use this when you want full schema documentation for your metadata type. + /// Documents a successful response whose body contains both a and + /// a metadata object. The response type is documented as + /// . /// - /// The type of the result value. - /// The type of the metadata (for OpenAPI schema generation). + /// The type of the success value. + /// The type of the success metadata. /// The route handler builder. /// The HTTP status code (default 200). /// The content type (default "application/json"). /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableResult( + public static RouteHandlerBuilder ProducesPortableSuccessResponse( this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, string contentType = "application/json" - ) - { - return builder.Produces>(statusCode, contentType); - } + ) => + builder.Produces>(statusCode, contentType); /// - /// Adds OpenAPI response metadata for LightSuccessResult with untyped metadata. - /// The metadata will be documented as an object with additionalProperties. + /// Documents a Light.PortableResults problem details failure response with untyped metadata. + /// Use the relevant (e.g. 401, 403, 404, 409, 500) to document + /// the expected non-validation failure response for the endpoint. /// - /// The type of the result value. /// The route handler builder. - /// The HTTP status code (default 200). - /// The content type (default "application/json"). + /// The HTTP status code (default 500 Internal Server Error). + /// The content type (default "application/problem+json"). /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableResult( + public static RouteHandlerBuilder ProducesPortableProblem( this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) - { - return builder.Produces>(statusCode, contentType); - } + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json" + ) => + builder.Produces(statusCode, contentType); + + /// + /// Documents a Light.PortableResults problem details failure response with strongly typed metadata. + /// Use the relevant to document the expected non-validation failure + /// response for the endpoint. + /// + /// The type of the metadata on each error. + /// The type of the top-level problem metadata. + /// The route handler builder. + /// The HTTP status code (default 500 Internal Server Error). + /// The content type (default "application/problem+json"). + /// The route handler builder for chaining. + public static RouteHandlerBuilder ProducesPortableProblem( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json" + ) => + builder.Produces>(statusCode, contentType); + + /// + /// Documents a rich Light.PortableResults validation problem details response with untyped metadata. + /// Use this helper when ValidationProblemSerializationFormat is set to + /// Rich. + /// + /// The route handler builder. + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + /// The route handler builder for chaining. + public static RouteHandlerBuilder ProducesPortableRichValidationProblem( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) => + builder.Produces(statusCode, contentType); + + /// + /// Documents a rich Light.PortableResults validation problem details response with strongly typed + /// metadata. Use this helper when ValidationProblemSerializationFormat is set to + /// Rich. + /// + /// The type of the metadata on each validation error. + /// The type of the top-level problem metadata. + /// The route handler builder. + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + /// The route handler builder for chaining. + public static RouteHandlerBuilder ProducesPortableRichValidationProblem( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) => + builder.Produces>( + statusCode, + contentType + ); + + /// + /// Documents an ASP.NET Core-compatible Light.PortableResults validation problem details response with + /// untyped metadata. Use this helper when ValidationProblemSerializationFormat is set to + /// AspNetCoreCompatible. + /// + /// The route handler builder. + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + /// The route handler builder for chaining. + public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) => + builder.Produces(statusCode, contentType); + + /// + /// Documents an ASP.NET Core-compatible Light.PortableResults validation problem details response with + /// strongly typed metadata. Use this helper when ValidationProblemSerializationFormat is set to + /// AspNetCoreCompatible. + /// + /// The type of the metadata on each error details entry. + /// The type of the top-level problem metadata. + /// The route handler builder. + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + /// The route handler builder for chaining. + public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem< + TErrorDetailMetadata, + TProblemMetadata + >( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) => + builder.Produces>( + statusCode, + contentType + ); } diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs new file mode 100644 index 0000000..4a4c8b8 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs @@ -0,0 +1,54 @@ +using Light.PortableResults.AspNetCore.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Light.PortableResults.AspNetCore.Mvc; + +/// +/// Specifies that the action produces an ASP.NET Core-compatible Light.PortableResults validation +/// problem details response with untyped metadata. Use this attribute when +/// ValidationProblemSerializationFormat is set to AspNetCoreCompatible. The response +/// type is documented as . +/// +public sealed class ProducesPortableAspNetCoreValidationProblemAttribute : + ProducesResponseTypeAttribute +{ + /// + /// Initializes a new instance of + /// . + /// + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + public ProducesPortableAspNetCoreValidationProblemAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) { } +} + +/// +/// Specifies that the action produces an ASP.NET Core-compatible Light.PortableResults validation +/// problem details response with strongly typed metadata. Use this attribute when +/// ValidationProblemSerializationFormat is set to AspNetCoreCompatible. The response +/// type is documented as +/// . +/// +/// The type of the metadata on each error details entry. +/// The type of the top-level problem metadata. +public sealed class ProducesPortableAspNetCoreValidationProblemAttribute< + TErrorDetailMetadata, + TProblemMetadata +> : ProducesResponseTypeAttribute< + PortableAspNetCoreValidationProblemDetails +> +{ + /// + /// Initializes a new instance of + /// . + /// + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + public ProducesPortableAspNetCoreValidationProblemAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) { } +} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs new file mode 100644 index 0000000..ea72530 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs @@ -0,0 +1,44 @@ +using Light.PortableResults.AspNetCore.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Light.PortableResults.AspNetCore.Mvc; + +/// +/// Specifies that the action produces a Light.PortableResults problem details failure response with +/// untyped metadata. The response type is documented as . +/// +public sealed class ProducesPortableProblemAttribute : ProducesResponseTypeAttribute +{ + /// + /// Initializes a new instance of . + /// + /// The HTTP status code (default 500 Internal Server Error). + /// The content type (default "application/problem+json"). + public ProducesPortableProblemAttribute( + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) { } +} + +/// +/// Specifies that the action produces a Light.PortableResults problem details failure response with +/// strongly typed metadata. The response type is documented as +/// . +/// +/// The type of the metadata on each error. +/// The type of the top-level problem metadata. +public sealed class ProducesPortableProblemAttribute : + ProducesResponseTypeAttribute> +{ + /// + /// Initializes a new instance of + /// . + /// + /// The HTTP status code (default 500 Internal Server Error). + /// The content type (default "application/problem+json"). + public ProducesPortableProblemAttribute( + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) { } +} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableResultAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableResultAttribute.cs deleted file mode 100644 index f3d89ae..0000000 --- a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableResultAttribute.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Mvc; - -/// -/// Specifies the type of the value and metadata returned by the action for OpenAPI documentation. -/// The response type is documented as . -/// -/// The type of the result value. -/// The type of the metadata (for OpenAPI schema generation). -public sealed class ProducesPortableResultAttribute : - ProducesResponseTypeAttribute> -{ - /// - /// Initializes a new instance of . - /// - /// The HTTP status code (default 200). - /// The content type (default "application/json"). - public ProducesPortableResultAttribute( - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) : base(statusCode, contentType) { } -} - -/// -/// Specifies the type of the value returned by the action for OpenAPI documentation. -/// The metadata will be documented as an object with additionalProperties. -/// The response type is documented as with object metadata. -/// -/// The type of the result value. -public sealed class ProducesPortableResultAttribute : - ProducesResponseTypeAttribute> -{ - /// - /// Initializes a new instance of . - /// - /// The HTTP status code (default 200). - /// The content type (default "application/json"). - public ProducesPortableResultAttribute( - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) : base(statusCode, contentType) { } -} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs new file mode 100644 index 0000000..b405c00 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs @@ -0,0 +1,48 @@ +using Light.PortableResults.AspNetCore.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Light.PortableResults.AspNetCore.Mvc; + +/// +/// Specifies that the action produces a rich Light.PortableResults validation problem details response +/// with untyped metadata. Use this attribute when ValidationProblemSerializationFormat is set +/// to Rich. The response type is documented as +/// . +/// +public sealed class ProducesPortableRichValidationProblemAttribute : + ProducesResponseTypeAttribute +{ + /// + /// Initializes a new instance of . + /// + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + public ProducesPortableRichValidationProblemAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) { } +} + +/// +/// Specifies that the action produces a rich Light.PortableResults validation problem details response +/// with strongly typed metadata. Use this attribute when ValidationProblemSerializationFormat +/// is set to Rich. The response type is documented as +/// . +/// +/// The type of the metadata on each validation error. +/// The type of the top-level problem metadata. +public sealed class ProducesPortableRichValidationProblemAttribute : + ProducesResponseTypeAttribute> +{ + /// + /// Initializes a new instance of + /// . + /// + /// The HTTP status code (default 400 Bad Request). + /// The content type (default "application/problem+json"). + public ProducesPortableRichValidationProblemAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) { } +} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs new file mode 100644 index 0000000..52d841f --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs @@ -0,0 +1,26 @@ +using Light.PortableResults.AspNetCore.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Light.PortableResults.AspNetCore.Mvc; + +/// +/// Specifies the response type for a successful action whose body contains both a +/// and a metadata object. The response +/// type is documented as for OpenAPI purposes. +/// +/// The type of the success value. +/// The type of the success metadata. +public sealed class ProducesPortableSuccessResponseAttribute : + ProducesResponseTypeAttribute> +{ + /// + /// Initializes a new instance of . + /// + /// The HTTP status code (default 200). + /// The content type (default "application/json"). + public ProducesPortableSuccessResponseAttribute( + int statusCode = StatusCodes.Status200OK, + string contentType = "application/json" + ) : base(statusCode, contentType) { } +} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs new file mode 100644 index 0000000..56f3366 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents an ASP.NET Core-compatible Light.PortableResults validation problem details response. +/// The inherited Errors property from is documented +/// as Dictionary<string, string[]>; the optional array carries +/// Light.PortableResults-specific information such as codes, categories, and metadata. +/// Use this type when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat +/// is set to AspNetCoreCompatible. +/// +/// The type of the metadata on each error details entry. +/// The type of the top-level problem metadata. +public class PortableAspNetCoreValidationProblemDetails + : HttpValidationProblemDetails +{ + /// + /// Gets or sets the optional Light.PortableResults-specific error details that correlate with + /// the inherited errors dictionary. + /// + public IReadOnlyList>? ErrorDetails { get; init; } + + /// + /// Gets or sets the top-level problem metadata. + /// + public TProblemMetadata Metadata { get; init; } = default!; +} + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Convenience non-generic variant of +/// +/// that uses for both metadata type parameters. +/// +public class PortableAspNetCoreValidationProblemDetails + : PortableAspNetCoreValidationProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs new file mode 100644 index 0000000..32ac32b --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs @@ -0,0 +1,67 @@ +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a single Light.PortableResults error item as it appears in rich +/// problem details responses with untyped metadata. +/// +public class PortableError +{ + /// + /// Gets or sets the human-readable error message. + /// + public string Message { get; init; } = string.Empty; + + /// + /// Gets or sets the stable machine-readable error code. + /// + public string? Code { get; init; } + + /// + /// Gets or sets the target (e.g. the offending input field) of the error. + /// + public string? Target { get; init; } + + /// + /// Gets or sets the error category. + /// + public ErrorCategory Category { get; init; } + + /// + /// Gets or sets the optional metadata associated with the error. + /// + public object? Metadata { get; init; } +} + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a single Light.PortableResults error item with strongly typed metadata. +/// +/// The type of the per-error metadata. +public class PortableError +{ + /// + /// Gets or sets the human-readable error message. + /// + public string Message { get; init; } = string.Empty; + + /// + /// Gets or sets the stable machine-readable error code. + /// + public string? Code { get; init; } + + /// + /// Gets or sets the target (e.g. the offending input field) of the error. + /// + public string? Target { get; init; } + + /// + /// Gets or sets the error category. + /// + public ErrorCategory Category { get; init; } + + /// + /// Gets or sets the metadata associated with the error. + /// + public TMetadata Metadata { get; init; } = default!; +} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs new file mode 100644 index 0000000..e378b6c --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; + +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a rich Light.PortableResults problem details response for non-validation failures, +/// with strongly typed per-error metadata and top-level problem metadata. +/// +/// The type of the metadata on each . +/// The type of the top-level problem metadata. +public class PortableProblemDetails : ProblemDetails +{ + /// + /// Gets or sets the collection of errors that caused the failure. + /// + public IReadOnlyList> Errors { get; init; } = + new List>(); + + /// + /// Gets or sets the top-level problem metadata. + /// + public TProblemMetadata Metadata { get; init; } = default!; +} + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Convenience non-generic variant of +/// that uses for both metadata type parameters. +/// +public class PortableProblemDetails : PortableProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs new file mode 100644 index 0000000..a03c058 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; + +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a rich Light.PortableResults validation problem details response that documents the +/// errors property as an array of Light.PortableResults-style error objects. +/// Use this type when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat +/// is set to Rich. +/// +/// The type of the metadata on each . +/// The type of the top-level problem metadata. +public class PortableRichValidationProblemDetails : ProblemDetails +{ + /// + /// Gets or sets the collection of validation errors. + /// + public IReadOnlyList> Errors { get; init; } = + new List>(); + + /// + /// Gets or sets the top-level problem metadata. + /// + public TProblemMetadata Metadata { get; init; } = default!; +} + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Convenience non-generic variant of +/// +/// that uses for both metadata type parameters. +/// +public class PortableRichValidationProblemDetails : PortableRichValidationProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs new file mode 100644 index 0000000..d668ef1 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs @@ -0,0 +1,24 @@ +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a successful response body that contains both a value and metadata. +/// Use this helper only for wrapped success responses that serialize metadata in the body. +/// For plain success bodies, use the standard ASP.NET Core +/// OpenAPI metadata APIs such as Produces<TValue> or +/// ProducesResponseTypeAttribute<TValue> instead. +/// +/// The type of the success value. +/// The type of the success metadata. +public class PortableSuccessResponse +{ + /// + /// Gets or sets the result value. + /// + public TValue Value { get; init; } = default!; + + /// + /// Gets or sets the metadata associated with the value. + /// + public TMetadata Metadata { get; init; } = default!; +} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs new file mode 100644 index 0000000..5fa6be1 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs @@ -0,0 +1,70 @@ +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a single entry in the errorDetails array of an +/// ASP.NET Core-compatible validation problem details response, with untyped metadata. +/// +public class PortableValidationErrorDetail +{ + /// + /// Gets or sets the target (e.g. the offending input field) the error detail refers to. + /// + public string Target { get; init; } = string.Empty; + + /// + /// Gets or sets the zero-based position of the corresponding error message within + /// the errors[target] array for the same target. + /// + public int Index { get; init; } + + /// + /// Gets or sets the stable machine-readable error code. + /// + public string? Code { get; init; } + + /// + /// Gets or sets the optional error category. + /// + public ErrorCategory? Category { get; init; } + + /// + /// Gets or sets the optional metadata associated with the error detail. + /// + public object? Metadata { get; init; } +} + +/// +/// Schema-only type for OpenAPI documentation. Not used at runtime. +/// Represents a single entry in the errorDetails array of an +/// ASP.NET Core-compatible validation problem details response, with strongly typed metadata. +/// +/// The type of the per-error-detail metadata. +public class PortableValidationErrorDetail +{ + /// + /// Gets or sets the target (e.g. the offending input field) the error detail refers to. + /// + public string Target { get; init; } = string.Empty; + + /// + /// Gets or sets the zero-based position of the corresponding error message within + /// the errors[target] array for the same target. + /// + public int Index { get; init; } + + /// + /// Gets or sets the stable machine-readable error code. + /// + public string? Code { get; init; } + + /// + /// Gets or sets the optional error category. + /// + public ErrorCategory? Category { get; init; } + + /// + /// Gets or sets the metadata associated with the error detail. + /// + public TMetadata Metadata { get; init; } = default!; +} diff --git a/src/Light.PortableResults.AspNetCore.Shared/WrappedResponse.cs b/src/Light.PortableResults.AspNetCore.Shared/WrappedResponse.cs deleted file mode 100644 index ff729f9..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/WrappedResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a wrapped response with a value and metadata. -/// -/// The type of the value. -/// The type of the metadata. -public sealed class WrappedResponse -{ - /// - /// Gets or sets the result value. - /// - public TValue Value { get; init; } = default!; - - /// - /// Gets or sets the metadata. - /// - public TMetadata Metadata { get; init; } = default!; -} diff --git a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs index 575be94..328b545 100644 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs +++ b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs @@ -1,8 +1,11 @@ +using System; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Light.PortableResults.AspNetCore.Shared; using Light.PortableResults.Metadata; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Xunit; @@ -10,51 +13,111 @@ namespace Light.PortableResults.AspNetCore.MinimalApis.Tests; public sealed class PortableResultsEndpointExtensionsTests { - [Fact] - public void ProducesPortableResult_ShouldRegisterWrappedResponseMetadata() + public static TheoryData, Type, int, string> RegistrationCases { get; } = new () { - var builder = WebApplication.CreateBuilder(); - var app = builder.Build(); - - var routeBuilder = app.MapGet("/test", () => "ok"); - var returned = routeBuilder.ProducesPortableResult(); + { + builder => builder.ProducesPortableSuccessResponse(), + typeof(PortableSuccessResponse), + StatusCodes.Status200OK, + "application/json" + }, + { + builder => builder.ProducesPortableSuccessResponse>( + statusCode: 201 + ), + typeof(PortableSuccessResponse>), + StatusCodes.Status201Created, + "application/json" + }, + { + builder => builder.ProducesPortableProblem(), + typeof(PortableProblemDetails), + StatusCodes.Status500InternalServerError, + "application/problem+json" + }, + { + builder => builder.ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound), + typeof(PortableProblemDetails), + StatusCodes.Status404NotFound, + "application/problem+json" + }, + { + builder => builder.ProducesPortableProblem( + statusCode: StatusCodes.Status409Conflict + ), + typeof(PortableProblemDetails), + StatusCodes.Status409Conflict, + "application/problem+json" + }, + { + builder => builder.ProducesPortableRichValidationProblem(), + typeof(PortableRichValidationProblemDetails), + StatusCodes.Status400BadRequest, + "application/problem+json" + }, + { + builder => builder.ProducesPortableRichValidationProblem( + statusCode: StatusCodes.Status422UnprocessableEntity + ), + typeof(PortableRichValidationProblemDetails), + StatusCodes.Status422UnprocessableEntity, + "application/problem+json" + }, + { + builder => builder.ProducesPortableAspNetCoreValidationProblem(), + typeof(PortableAspNetCoreValidationProblemDetails), + StatusCodes.Status400BadRequest, + "application/problem+json" + }, + { + builder => builder.ProducesPortableAspNetCoreValidationProblem( + statusCode: StatusCodes.Status422UnprocessableEntity + ), + typeof(PortableAspNetCoreValidationProblemDetails), + StatusCodes.Status422UnprocessableEntity, + "application/problem+json" + } + }; - returned.Should().BeSameAs(routeBuilder); - - var endpointRouteBuilder = (IEndpointRouteBuilder) app; - var endpoint = endpointRouteBuilder.DataSources.Single().Endpoints.OfType().Single(); - var metadataEntries = endpoint.Metadata - .Where(item => item.GetType().Name == "ProducesResponseTypeMetadata") - .ToArray(); - - metadataEntries.Should().NotBeEmpty(); - metadataEntries - .Select(entry => entry.GetType().GetProperty("Type")?.GetValue(entry)) - .Should() - .Contain(typeof(WrappedResponse)); - } - - [Fact] - public void ProducesPortableResultWithMetadataType_ShouldRegisterWrappedResponseMetadata() + [Theory] + [MemberData(nameof(RegistrationCases))] + public void HelperShouldRegisterExpectedMetadata( + Action register, + Type expectedType, + int expectedStatusCode, + string expectedContentType + ) { var builder = WebApplication.CreateBuilder(); var app = builder.Build(); + var routeBuilder = app.MapGet("/test", () => "ok"); - var routeBuilder = app.MapGet("/test-metadata", () => "ok"); - var returned = routeBuilder.ProducesPortableResult(); - - returned.Should().BeSameAs(routeBuilder); + register(routeBuilder); var endpointRouteBuilder = (IEndpointRouteBuilder) app; var endpoint = endpointRouteBuilder.DataSources.Single().Endpoints.OfType().Single(); - var metadataEntries = endpoint.Metadata + var matches = endpoint.Metadata .Where(item => item.GetType().Name == "ProducesResponseTypeMetadata") + .Select( + entry => new + { + Type = (Type?) entry.GetType().GetProperty("Type")?.GetValue(entry), + StatusCode = (int?) entry.GetType().GetProperty("StatusCode")?.GetValue(entry), + ContentTypes = (IEnumerable?) entry + .GetType() + .GetProperty("ContentTypes") + ?.GetValue(entry) + } + ) .ToArray(); - metadataEntries.Should().NotBeEmpty(); - metadataEntries - .Select(entry => entry.GetType().GetProperty("Type")?.GetValue(entry)) + matches .Should() - .Contain(typeof(WrappedResponse)); + .ContainSingle( + entry => entry.Type == expectedType && + entry.StatusCode == expectedStatusCode && + entry.ContentTypes != null && + entry.ContentTypes.Contains(expectedContentType) + ); } } diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs index b2c03f2..fc45c07 100644 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs @@ -10,7 +10,7 @@ namespace Light.PortableResults.AspNetCore.Mvc.Tests.IntegrationTests; public sealed class RegularMvcController : ControllerBase { [HttpGet] - [ProducesPortableResult, Dictionary>] + [ProducesPortableSuccessResponse, Dictionary>] public LightActionResult> GetContacts() { var contact1 = new ContactDto { Id = new Guid("D8FC9BEC-0606-4E9B-8EB4-04558B2B9D40"), Name = "Foo" }; @@ -26,7 +26,7 @@ public LightActionResult> GetContacts() } [HttpGet("{id:guid}")] - [ProducesPortableResult] + [ProducesResponseType(200)] public LightActionResult GetContact(Guid id) { var contactDto = new ContactDto { Id = id, Name = "Foo" }; @@ -35,7 +35,7 @@ public LightActionResult GetContact(Guid id) } [HttpPut] - [ProducesPortableResult(statusCode: 201)] + [ProducesResponseType(201)] public LightActionResult CreateContact([FromBody] ContactDto contactDto) { var result = Result.Ok(contactDto); diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs new file mode 100644 index 0000000..d91b3d6 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Light.PortableResults.AspNetCore.Shared; +using Light.PortableResults.Metadata; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; +using Xunit; + +namespace Light.PortableResults.AspNetCore.Mvc.Tests.UnitTests; + +public sealed class ProducesPortableAttributesTests +{ + public static TheoryData AttributeCases { get; } = + new () + { + { + new ProducesPortableSuccessResponseAttribute(), + typeof(PortableSuccessResponse), + StatusCodes.Status200OK, + "application/json" + }, + { + new ProducesPortableSuccessResponseAttribute>( + statusCode: StatusCodes.Status201Created + ), + typeof(PortableSuccessResponse>), + StatusCodes.Status201Created, + "application/json" + }, + { + new ProducesPortableProblemAttribute(), + typeof(PortableProblemDetails), + StatusCodes.Status500InternalServerError, + "application/problem+json" + }, + { + new ProducesPortableProblemAttribute(statusCode: StatusCodes.Status404NotFound), + typeof(PortableProblemDetails), + StatusCodes.Status404NotFound, + "application/problem+json" + }, + { + new ProducesPortableProblemAttribute( + statusCode: StatusCodes.Status409Conflict + ), + typeof(PortableProblemDetails), + StatusCodes.Status409Conflict, + "application/problem+json" + }, + { + new ProducesPortableRichValidationProblemAttribute(), + typeof(PortableRichValidationProblemDetails), + StatusCodes.Status400BadRequest, + "application/problem+json" + }, + { + new ProducesPortableRichValidationProblemAttribute( + statusCode: StatusCodes.Status422UnprocessableEntity + ), + typeof(PortableRichValidationProblemDetails), + StatusCodes.Status422UnprocessableEntity, + "application/problem+json" + }, + { + new ProducesPortableAspNetCoreValidationProblemAttribute(), + typeof(PortableAspNetCoreValidationProblemDetails), + StatusCodes.Status400BadRequest, + "application/problem+json" + }, + { + new ProducesPortableAspNetCoreValidationProblemAttribute( + statusCode: StatusCodes.Status422UnprocessableEntity + ), + typeof(PortableAspNetCoreValidationProblemDetails), + StatusCodes.Status422UnprocessableEntity, + "application/problem+json" + } + }; + + [Theory] + [MemberData(nameof(AttributeCases))] + public void AttributeExposesExpectedMetadata( + ProducesResponseTypeAttribute attribute, + Type expectedType, + int expectedStatusCode, + string expectedContentType + ) + { + var contentTypes = new MediaTypeCollection(); + ((IApiResponseMetadataProvider) attribute).SetContentTypes(contentTypes); + + attribute.Type.Should().Be(expectedType); + attribute.StatusCode.Should().Be(expectedStatusCode); + contentTypes.Should().Contain(expectedContentType); + } +} From cf4940b153c0d125219f29751cb7ee5618d3a198 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 18 Apr 2026 14:43:40 +0200 Subject: [PATCH 03/67] chore: add OpenAPI support for NativeAotMovieRating project Signed-off-by: Kenny Pflug --- Directory.Packages.props | 2 ++ .../AddMovieRating/AddMovieRatingEndpoint.cs | 13 +++++++++- .../GetMovies/GetMoviesEndpoint.cs | 15 ++++++++++- .../MovieRatingJsonContext.cs | 12 +++++++++ .../NativeAotMovieRating.csproj | 2 ++ samples/NativeAotMovieRating/Program.cs | 7 ++++- .../NativeAotMovieRating/packages.lock.json | 26 ++++++++++++++++--- samples/NativeAotMovieRating/requests.http | 6 +++++ .../packages.lock.json | 2 +- 9 files changed, 78 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 87635af..03f2b11 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + @@ -17,6 +18,7 @@ + diff --git a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs index 8b77259..72f716f 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs +++ b/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs @@ -3,13 +3,24 @@ using Light.PortableResults.AspNetCore.MinimalApis; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using NativeAotMovieRating.InMemoryDatabaseAccess; namespace NativeAotMovieRating.AddMovieRating; public static class AddMovieRatingEndpoint { public static void MapAddMovieRatingEndpoint(this WebApplication app) => - app.MapPut("/api/moviesRatings", AddMovieRating); + app + .MapPut("/api/moviesRatings", AddMovieRating) + .WithName("AddMovieRating") + .WithTags("Movie Ratings") + .WithSummary("Adds or updates a movie rating.") + .WithDescription( + "Validates the request and stores the movie rating. Returns the stored rating on success, or a rich Light.PortableResults problem details response on validation or lookup failures." + ) + .Produces() + .ProducesPortableRichValidationProblem() + .ProducesPortableProblem(); private static async Task AddMovieRating( MovieRatingDto dto, diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index 41e445f..31b1788 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Light.PortableResults; @@ -7,13 +8,25 @@ using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using NativeAotMovieRating.InMemoryDatabaseAccess; namespace NativeAotMovieRating.GetMovies; public static class GetMoviesEndpoint { public static void MapGetMoviesEndpoint(this WebApplication app) => - app.MapGet("/api/movies", GetMovies); + app + .MapGet("/api/movies", GetMovies) + .WithName("GetMovies") + .WithTags("Movies") + .WithSummary("Returns a paginated list of movies.") + .WithDescription( + "Supports keyset pagination via the optional lastKnownMovieId parameter and a configurable " + + "page size via the take parameter (1-40). Returns a rich Light.PortableResults problem " + + "details response when input validation fails or when the lastKnownMovieId does not exist." + ) + .Produces>() + .ProducesPortableRichValidationProblem(); private static async Task GetMovies( IGetMoviesSession session, diff --git a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs index 8e0d4a7..cbfd60a 100644 --- a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs +++ b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +using Light.PortableResults.AspNetCore.Shared; using Light.PortableResults.Http.Writing; using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.InMemoryDatabaseAccess; @@ -9,6 +11,16 @@ namespace NativeAotMovieRating.JsonSerialization; [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(MovieRatingDto))] +[JsonSerializable(typeof(MovieRating))] [JsonSerializable(typeof(HttpResultForWriting))] [JsonSerializable(typeof(List))] +// Primitive/parameter types that appear in endpoint signatures. Microsoft.AspNetCore.OpenApi +// requests JsonTypeInfo for each of them when building the OpenAPI document, and source-gen-only +// JSON (AOT mode) will not resolve them implicitly. +[JsonSerializable(typeof(Guid?))] +[JsonSerializable(typeof(int))] +// Schema-only types registered so Microsoft.AspNetCore.OpenApi can emit JSON schemas under +// the AOT-friendly source-gen serializer options configured for this app. +[JsonSerializable(typeof(PortableRichValidationProblemDetails))] +[JsonSerializable(typeof(PortableProblemDetails))] public sealed partial class MovieRatingJsonContext : JsonSerializerContext; diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index 015de1b..28becf0 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -15,6 +15,8 @@ + + diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index 6ba92e5..c4b5a9c 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -6,6 +6,7 @@ using NativeAotMovieRating.GetMovies; using NativeAotMovieRating.InMemoryDatabaseAccess; using NativeAotMovieRating.JsonSerialization; +using Scalar.AspNetCore; using Serilog; using Serilog.Events; @@ -23,12 +24,16 @@ .AddInMemoryDatabase() .AddGetMoviesModule() .AddAddMovieRatingModule() - .AddHealthChecks(); + .AddHealthChecks() + .Services + .AddOpenApi(); var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseRouting(); app.UseHealthChecks("/"); +app.MapOpenApi(); +app.MapScalarApiReference(options => options.WithTitle("Native AOT Movie Rating API")); app.MapGetMoviesEndpoint(); app.MapAddMovieRatingEndpoint(); diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 2223bf6..779cca8 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -11,6 +11,15 @@ "Light.GuardClauses": "13.0.0" } }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, "Microsoft.DotNet.ILCompiler": { "type": "Direct", "requested": "[10.0.3, )", @@ -23,6 +32,12 @@ "resolved": "10.0.3", "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" }, + "Scalar.AspNetCore": { + "type": "Direct", + "requested": "[2.14.1, )", + "resolved": "2.14.1", + "contentHash": "neS3aI7YVPDY7+I9U8nYXvSnSy6lPvGyn5w52m0MCvC5COajK4HF4vsx70OO6Fw9bd7WXOUikSBdUaLfyTzw3g==" + }, "Serilog.AspNetCore": { "type": "Direct", "requested": "[10.0.0, )", @@ -48,6 +63,11 @@ "resolved": "10.0.0", "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, "Serilog": { "type": "Transitive", "resolved": "4.3.0", @@ -121,19 +141,19 @@ "light.portableresults.aspnetcore.minimalapis": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.1.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" } }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.1.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "light.portableresults.validation": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.1.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.HashCode": { diff --git a/samples/NativeAotMovieRating/requests.http b/samples/NativeAotMovieRating/requests.http index 82332d3..0f9e53e 100644 --- a/samples/NativeAotMovieRating/requests.http +++ b/samples/NativeAotMovieRating/requests.http @@ -1,6 +1,12 @@ ### Health http://localhost:5000 +### OpenAPI document (JSON) +http://localhost:5000/openapi/v1.json + +### Scalar API reference (open in browser) +http://localhost:5000/scalar/v1 + ### Get Movies (first page) http://localhost:5000/api/movies diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json index 60b3cf4..291eb2b 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json @@ -47,7 +47,7 @@ "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.3.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.HashCode": { From 0d36c9781e06d2e41fcac6ca81d1c179bee1afc0 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 18 Apr 2026 15:04:03 +0200 Subject: [PATCH 04/67] test: increase coverage for OpenAPI types Signed-off-by: Kenny Pflug --- .../HttpExtensionsTests.cs | 327 ++++++++++++++++++ .../PortableSchemaTypesTests.cs | 259 ++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs create mode 100644 tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs new file mode 100644 index 0000000..c2d1783 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Net; +using FluentAssertions; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.Http.Writing.Headers; +using Light.PortableResults.Metadata; +using Light.PortableResults.SharedJsonSerialization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Light.PortableResults.AspNetCore.Shared.Tests; + +public sealed class HttpExtensionsTests +{ + [Fact] + public void SetStatusCodeFromResult_ShouldThrow_WhenResponseIsNull() + { + HttpResponse? response = null; + var result = Result.Ok(); + + var act = () => response!.SetStatusCodeFromResult(result); + + act.Should().ThrowExactly().WithParameterName("httpResponse"); + } + + [Fact] + public void SetStatusCodeFromResult_ShouldSetOk_WhenResultIsSuccessfulAndNoOverrideWasProvided() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok(); + + response.SetStatusCodeFromResult(result); + + response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public void SetStatusCodeFromResult_ShouldUseProvidedSuccessStatusCode_WhenResultIsSuccessful() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok(); + + response.SetStatusCodeFromResult(result, HttpStatusCode.Created); + + response.StatusCode.Should().Be(StatusCodes.Status201Created); + } + + [Fact] + public void SetStatusCodeFromResult_ShouldUseFirstErrorCategory_WhenConfiguredToDoSo() + { + var response = new DefaultHttpContext().Response; + var result = Result.Fail( + new[] + { + new Error { Message = "Not Found", Category = ErrorCategory.NotFound }, + new Error { Message = "Conflict", Category = ErrorCategory.Conflict } + } + ); + + response.SetStatusCodeFromResult(result, firstErrorCategoryIsLeadingCategory: true); + + response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public void SetStatusCodeFromResult_ShouldUseUnclassified_WhenMultipleErrorsHaveDifferentCategories() + { + var response = new DefaultHttpContext().Response; + var result = Result.Fail( + new[] + { + new Error { Message = "Unauthorized", Category = ErrorCategory.Unauthorized }, + new Error { Message = "Conflict", Category = ErrorCategory.Conflict } + } + ); + + response.SetStatusCodeFromResult(result, firstErrorCategoryIsLeadingCategory: false); + + response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + } + + [Fact] + public void SetContentTypeFromResult_ShouldThrow_WhenResponseIsNull() + { + HttpResponse? response = null; + var result = Result.Ok(); + + var act = () => response!.SetContentTypeFromResult(result, MetadataSerializationMode.Always); + + act.Should().ThrowExactly().WithParameterName("httpResponse"); + } + + [Fact] + public void SetContentTypeFromResult_ShouldSetProblemJson_WhenResultIsInvalid() + { + var response = new DefaultHttpContext().Response; + var result = Result.Fail(new Error { Message = "Failure", Category = ErrorCategory.InternalError }); + + response.SetContentTypeFromResult(result, MetadataSerializationMode.ErrorsOnly); + + response.ContentType.Should().Be("application/problem+json"); + } + + [Fact] + public void SetContentTypeFromResult_ShouldSetJson_WhenSuccessfulResultHasAValue() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok(42); + + response.SetContentTypeFromResult(result, MetadataSerializationMode.ErrorsOnly); + + response.ContentType.Should().Be("application/json"); + } + + [Fact] + public void SetContentTypeFromResult_ShouldLeaveContentTypeUnset_WhenSuccessfulResultHasNoValueAndNoMetadata() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok(); + + response.SetContentTypeFromResult(result, MetadataSerializationMode.Always); + + response.ContentType.Should().BeNull(); + } + + [Fact] + public void + SetContentTypeFromResult_ShouldLeaveContentTypeUnset_WhenMetadataShouldNotBeSerializedForSuccessResults() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok( + MetadataObject.Create( + ("traceId", MetadataValue.FromString("trace-42", MetadataValueAnnotation.SerializeInHttpResponseBody)) + ) + ); + + response.SetContentTypeFromResult(result, MetadataSerializationMode.ErrorsOnly); + + response.ContentType.Should().BeNull(); + } + + [Fact] + public void SetContentTypeFromResult_ShouldLeaveContentTypeUnset_WhenSuccessfulMetadataHasNoBodyAnnotatedValues() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok( + MetadataObject.Create( + ("traceId", MetadataValue.FromString("trace-42", MetadataValueAnnotation.SerializeInHttpHeader)) + ) + ); + + response.SetContentTypeFromResult(result, MetadataSerializationMode.Always); + + response.ContentType.Should().BeNull(); + } + + [Fact] + public void SetContentTypeFromResult_ShouldSetJson_WhenSuccessfulMetadataContainsBodyAnnotatedValues() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok( + MetadataObject.Create( + ("traceId", MetadataValue.FromString("trace-42", MetadataValueAnnotation.SerializeInHttpHeaderAndBody)) + ) + ); + + response.SetContentTypeFromResult(result, MetadataSerializationMode.Always); + + response.ContentType.Should().Be("application/json"); + } + + [Fact] + public void SetMetadataValuesAsHeadersIfNecessary_ShouldThrow_WhenResponseIsNull() + { + HttpResponse? response = null; + var result = Result.Ok(); + var conversionService = new CapturingHttpHeaderConversionService(); + + var act = () => response!.SetMetadataValuesAsHeadersIfNecessary(result, conversionService); + + act.Should().ThrowExactly().WithParameterName("httpResponse"); + } + + [Fact] + public void SetMetadataValuesAsHeadersIfNecessary_ShouldThrow_WhenConversionServiceIsNull() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok(); + + var act = () => response.SetMetadataValuesAsHeadersIfNecessary(result, null!); + + act.Should().ThrowExactly().WithParameterName("conversionService"); + } + + [Fact] + public void SetMetadataValuesAsHeadersIfNecessary_ShouldDoNothing_WhenResultHasNoMetadata() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok(); + var conversionService = new CapturingHttpHeaderConversionService(); + + response.SetMetadataValuesAsHeadersIfNecessary(result, conversionService); + + conversionService.PreparedHeaders.Should().BeEmpty(); + response.Headers.Should().BeEmpty(); + } + + [Fact] + public void SetMetadataValuesAsHeadersIfNecessary_ShouldSkipNullAndBodyOnlyMetadataValues() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok( + MetadataObject.Create( + ("nullHeader", MetadataValue.FromNull(MetadataValueAnnotation.SerializeInHttpHeader)), + ("bodyOnly", MetadataValue.FromString("body", MetadataValueAnnotation.SerializeInHttpResponseBody)) + ) + ); + var conversionService = new CapturingHttpHeaderConversionService(); + + response.SetMetadataValuesAsHeadersIfNecessary(result, conversionService); + + conversionService.PreparedHeaders.Should().BeEmpty(); + response.Headers.Should().BeEmpty(); + } + + [Fact] + public void SetMetadataValuesAsHeadersIfNecessary_ShouldAddHeaders_ForHeaderAnnotatedMetadataValues() + { + var response = new DefaultHttpContext().Response; + var result = Result.Ok( + MetadataObject.Create( + ("X-TraceId", MetadataValue.FromString("trace-42", MetadataValueAnnotation.SerializeInHttpHeader)), + ("bodyOnly", MetadataValue.FromString("body", MetadataValueAnnotation.SerializeInHttpResponseBody)) + ) + ); + var conversionService = new CapturingHttpHeaderConversionService(); + + response.SetMetadataValuesAsHeadersIfNecessary(result, conversionService); + + conversionService.PreparedHeaders.Should().ContainSingle(); + response.Headers["X-TraceId"].Should().BeEquivalentTo(); + } + + [Fact] + public void ResolvePortableResultsHttpWriteOptions_ShouldThrow_WhenHttpContextIsNull() + { + HttpContext? httpContext = null; + + Action act = () => httpContext!.ResolvePortableResultsHttpWriteOptions(); + + act.Should().ThrowExactly().WithParameterName("httpContext"); + } + + [Fact] + public void ResolvePortableResultsHttpWriteOptions_ShouldReturnOverride_WhenProvided() + { + var httpContext = CreateHttpContext(); + var expectedOptions = new PortableResultsHttpWriteOptions + { + MetadataSerializationMode = MetadataSerializationMode.Always + }; + + var actualOptions = httpContext.ResolvePortableResultsHttpWriteOptions(expectedOptions); + + actualOptions.Should().BeSameAs(expectedOptions); + } + + [Fact] + public void ResolvePortableResultsHttpWriteOptions_ShouldResolveOptionsFromRequestServices() + { + var expectedOptions = new PortableResultsHttpWriteOptions + { + MetadataSerializationMode = MetadataSerializationMode.Always, + FirstErrorCategoryIsLeadingCategory = false + }; + var httpContext = CreateHttpContext(expectedOptions); + + var actualOptions = httpContext.ResolvePortableResultsHttpWriteOptions(); + + actualOptions.Should().BeSameAs(expectedOptions); + } + + [Fact] + public void ResolvePortableResultsHttpWriteOptions_ShouldThrow_WhenNeitherOverrideNorRegisteredOptionsAreAvailable() + { + var httpContext = CreateHttpContext(); + + Action act = () => httpContext.ResolvePortableResultsHttpWriteOptions(); + + act.Should() + .ThrowExactly() + .WithMessage("No PortableResultsHttpWriteOptions are configured in the DI container"); + } + + private static DefaultHttpContext CreateHttpContext(PortableResultsHttpWriteOptions? options = null) + { + var services = new ServiceCollection(); + + if (options is not null) + { + services.AddSingleton(Options.Create(options)); + } + + return new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + } + + private sealed class CapturingHttpHeaderConversionService : IHttpHeaderConversionService + { + public List> PreparedHeaders { get; } = new (); + + public KeyValuePair PrepareHttpHeader(string metadataKey, MetadataValue metadataValue) + { + metadataValue.TryGetString(out var stringValue).Should().BeTrue(); + + var preparedHeader = new KeyValuePair(metadataKey, new StringValues(stringValue)); + PreparedHeaders.Add(preparedHeader); + return preparedHeader; + } + } +} diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs new file mode 100644 index 0000000..6aec87f --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs @@ -0,0 +1,259 @@ +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace Light.PortableResults.AspNetCore.Shared.Tests; + +public sealed class PortableSchemaTypesTests +{ + [Fact] + public void PortableError_ShouldExposeAssignedValues() + { + var sut = new PortableError + { + Message = "Validation failed", + Code = "too_short", + Target = "name", + Category = ErrorCategory.Validation, + Metadata = new { MinLength = 3 } + }; + + sut.Should().BeEquivalentTo( + new + { + Message = "Validation failed", + Code = "too_short", + Target = "name", + Category = ErrorCategory.Validation, + Metadata = new { MinLength = 3 } + } + ); + } + + [Fact] + public void PortableErrorOfT_ShouldExposeAssignedValues() + { + var sut = new PortableError + { + Message = "Validation failed", + Code = "too_short", + Target = "name", + Category = ErrorCategory.Validation, + Metadata = new ErrorMetadata { MinLength = 3 } + }; + + sut.Should().BeEquivalentTo( + new PortableError + { + Message = "Validation failed", + Code = "too_short", + Target = "name", + Category = ErrorCategory.Validation, + Metadata = new ErrorMetadata { MinLength = 3 } + } + ); + } + + [Fact] + public void PortableProblemDetails_ShouldInitializeErrorsAndExposeMetadata() + { + var metadata = new ProblemMetadata { TraceId = "trace-42" }; + var error = new PortableError + { + Message = "Not found", + Code = "not_found", + Target = "contactId", + Category = ErrorCategory.NotFound, + Metadata = new ErrorMetadata { MinLength = 0 } + }; + var sut = new PortableProblemDetails + { + Title = "Problem", + Status = 404, + Errors = new[] { error }, + Metadata = metadata + }; + + sut.Errors.Should().ContainSingle().Which.Should().BeEquivalentTo(error); + sut.Metadata.Should().BeEquivalentTo(metadata); + } + + [Fact] + public void PortableProblemDetails_DefaultVariantShouldUseObjectMetadataTypes() + { + var sut = new PortableProblemDetails(); + + sut.Errors.Should().BeEmpty(); + sut.Metadata.Should().BeNull(); + } + + [Fact] + public void PortableRichValidationProblemDetails_ShouldInitializeErrorsAndExposeMetadata() + { + var metadata = new ProblemMetadata { TraceId = "trace-43" }; + var error = new PortableError + { + Message = "Too short", + Code = "too_short", + Target = "name", + Category = ErrorCategory.Validation, + Metadata = new ErrorMetadata { MinLength = 3 } + }; + var sut = new PortableRichValidationProblemDetails + { + Title = "Validation failed", + Status = 400, + Errors = new[] { error }, + Metadata = metadata + }; + + sut.Errors.Should().ContainSingle().Which.Should().BeEquivalentTo(error); + sut.Metadata.Should().BeEquivalentTo(metadata); + } + + [Fact] + public void PortableRichValidationProblemDetails_DefaultVariantShouldUseObjectMetadataTypes() + { + var sut = new PortableRichValidationProblemDetails(); + + sut.Errors.Should().BeEmpty(); + sut.Metadata.Should().BeNull(); + } + + [Fact] + public void PortableAspNetCoreValidationProblemDetails_ShouldExposeErrorDetailsAndMetadata() + { + var errorDetail = new PortableValidationErrorDetail + { + Target = "name", + Index = 1, + Code = "too_short", + Category = ErrorCategory.Validation, + Metadata = new ErrorMetadata { MinLength = 3 } + }; + var metadata = new ProblemMetadata { TraceId = "trace-44" }; + var sut = new PortableAspNetCoreValidationProblemDetails + { + ErrorDetails = new[] { errorDetail }, + Metadata = metadata + }; + + sut.ErrorDetails.Should().ContainSingle().Which.Should().BeEquivalentTo(errorDetail); + sut.Metadata.Should().BeEquivalentTo(metadata); + } + + [Fact] + public void PortableAspNetCoreValidationProblemDetails_DefaultVariantShouldUseObjectMetadataTypes() + { + var sut = new PortableAspNetCoreValidationProblemDetails(); + + sut.ErrorDetails.Should().BeNull(); + sut.Metadata.Should().BeNull(); + } + + [Fact] + public void PortableValidationErrorDetail_ShouldExposeAssignedValues() + { + var sut = new PortableValidationErrorDetail + { + Target = "name", + Index = 2, + Code = "too_short", + Category = ErrorCategory.Validation, + Metadata = new { MinLength = 3 } + }; + + sut.Should().BeEquivalentTo( + new + { + Target = "name", + Index = 2, + Code = "too_short", + Category = ErrorCategory.Validation, + Metadata = new { MinLength = 3 } + } + ); + } + + [Fact] + public void PortableValidationErrorDetailOfT_ShouldExposeAssignedValues() + { + var sut = new PortableValidationErrorDetail + { + Target = "name", + Index = 2, + Code = "too_short", + Category = ErrorCategory.Validation, + Metadata = new ErrorMetadata { MinLength = 3 } + }; + + sut.Should().BeEquivalentTo( + new PortableValidationErrorDetail + { + Target = "name", + Index = 2, + Code = "too_short", + Category = ErrorCategory.Validation, + Metadata = new ErrorMetadata { MinLength = 3 } + } + ); + } + + [Fact] + public void PortableSuccessResponse_ShouldExposeValueAndMetadata() + { + var value = new SuccessValue { Name = "Alice" }; + var metadata = new ProblemMetadata { TraceId = "trace-45" }; + var sut = new PortableSuccessResponse + { + Value = value, + Metadata = metadata + }; + + sut.Should().BeEquivalentTo( + new PortableSuccessResponse + { + Value = value, + Metadata = metadata + } + ); + } + + [Fact] + public void PortableProblemDetails_GenericErrorsPropertyShouldAcceptReadOnlyCollections() + { + IReadOnlyList> errors = + [ + new() + { + Message = "Conflict", + Code = "duplicate", + Target = "email", + Category = ErrorCategory.Conflict, + Metadata = new ErrorMetadata { MinLength = 0 } + } + ]; + var sut = new PortableProblemDetails + { + Errors = errors, + Metadata = new ProblemMetadata { TraceId = "trace-46" } + }; + + sut.Errors.Should().BeSameAs(errors); + } + + private sealed class ErrorMetadata + { + public int MinLength { get; init; } + } + + private sealed class ProblemMetadata + { + public string TraceId { get; init; } = string.Empty; + } + + private sealed class SuccessValue + { + public string Name { get; init; } = string.Empty; + } +} From c0b43aeceea55a9aab6349b975a5fedb33289e8c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 18 Apr 2026 15:10:58 +0200 Subject: [PATCH 05/67] test: fix failing test Signed-off-by: Kenny Pflug --- .../HttpExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs index c2d1783..da42c0d 100644 --- a/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs +++ b/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs @@ -242,7 +242,7 @@ public void SetMetadataValuesAsHeadersIfNecessary_ShouldAddHeaders_ForHeaderAnno response.SetMetadataValuesAsHeadersIfNecessary(result, conversionService); conversionService.PreparedHeaders.Should().ContainSingle(); - response.Headers["X-TraceId"].Should().BeEquivalentTo(); + response.Headers["X-TraceId"].Should().Equal("trace-42"); } [Fact] From 112253b9a81f6684895c19e1e2aa1e971e42fe7e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 19 Apr 2026 07:20:50 +0200 Subject: [PATCH 06/67] chore: redirect to Scalar when home rout is accessed in NativeAotMovieRating project Signed-off-by: Kenny Pflug --- .../OpenApi/OpenApiModule.cs | 17 +++++++++++++++++ samples/NativeAotMovieRating/Program.cs | 8 ++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs diff --git a/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs b/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs new file mode 100644 index 0000000..b969a85 --- /dev/null +++ b/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Scalar.AspNetCore; + +namespace NativeAotMovieRating.OpenApi; + +public static class OpenApiModule +{ + public static void MapOpenApiAndScalar(this WebApplication app) + { + app.MapOpenApi(); + app.MapScalarApiReference("/docs", options => options.WithTitle("Native AOT Movie Rating API")); + } + + public static void RedirectHomeToDocs(this WebApplication app) => + app.MapGet("/", () => TypedResults.LocalRedirect("/docs")); +} diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index c4b5a9c..1c9d6cf 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -6,7 +6,7 @@ using NativeAotMovieRating.GetMovies; using NativeAotMovieRating.InMemoryDatabaseAccess; using NativeAotMovieRating.JsonSerialization; -using Scalar.AspNetCore; +using NativeAotMovieRating.OpenApi; using Serilog; using Serilog.Events; @@ -31,11 +31,11 @@ var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseRouting(); -app.UseHealthChecks("/"); -app.MapOpenApi(); -app.MapScalarApiReference(options => options.WithTitle("Native AOT Movie Rating API")); +app.UseHealthChecks("/health"); +app.MapOpenApiAndScalar(); app.MapGetMoviesEndpoint(); app.MapAddMovieRatingEndpoint(); +app.RedirectHomeToDocs(); try { From 49bb5a91c49dd05cd9766ad8a53bd2c61fb45860 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 19 Apr 2026 17:33:45 +0200 Subject: [PATCH 07/67] feat: add initial OpenAPI extension to the codebase Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/Program.cs | 8 +- .../NativeAotMovieRating/packages.lock.json | 16 ++++ ...tableAspNetCoreValidationProblemDetails.cs | 46 +++++++---- .../PortableError.cs | 47 +++++++---- .../PortableProblemDetails.cs | 30 ++++--- ...PortableResultsOpenApiNamingConventions.cs | 78 ++++++++++++++++++ .../PortableRichValidationProblemDetails.cs | 36 ++++++--- .../PortableSuccessResponse.cs | 26 +++--- .../PortableValidationErrorDetail.cs | 56 ++++++++----- ...bleResultsOpenApiNamingConventionsTests.cs | 80 +++++++++++++++++++ 10 files changed, 338 insertions(+), 85 deletions(-) create mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs create mode 100644 tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index 1c9d6cf..a3142a0 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -1,6 +1,8 @@ using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.Shared; using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.GetMovies; @@ -26,7 +28,11 @@ .AddAddMovieRatingModule() .AddHealthChecks() .Services - .AddOpenApi(); + .AddOpenApi( + options => options.CreateSchemaReferenceId = type => + PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(type) ?? + OpenApiOptions.CreateDefaultSchemaReferenceId(type) + ); var app = builder.Build(); app.UseSerilogRequestLogging(); diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 779cca8..02f29b6 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -168,6 +168,22 @@ "resolved": "1.4.1", "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } + }, + "net10.0/osx-arm64": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==", + "dependencies": { + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.3" + } + }, + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4bZ2RJrwpq/rqEBaiAn7gsqotp5jOGSu+P5fBSUMlRpmCkWnnNTCdDIBwCBemW1QkhH83d2JS9Bz71ng6oWY0g==" + } } } } \ No newline at end of file diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs index 56f3366..79b2985 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs @@ -4,36 +4,50 @@ namespace Light.PortableResults.AspNetCore.Shared; /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents an ASP.NET Core-compatible Light.PortableResults validation problem details response. -/// The inherited Errors property from is documented -/// as Dictionary<string, string[]>; the optional array carries -/// Light.PortableResults-specific information such as codes, categories, and metadata. -/// Use this type when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat -/// is set to AspNetCoreCompatible. +/// RFC 9457 problem details response returned for validation failures in the ASP.NET +/// Core-compatible format. The inherited errors property is a +/// Dictionary<string, string[]> (field name to messages) for compatibility with +/// clients that expect the ASP.NET Core default; the optional errorDetails array +/// supplies Light.PortableResults-specific information such as codes, categories, and +/// metadata for each message. /// -/// The type of the metadata on each error details entry. -/// The type of the top-level problem metadata. +/// The shape of the per-detail metadata on each errorDetails entry. +/// The shape of the top-level metadata bag. +/// +/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat +/// is set to AspNetCoreCompatible. This is a schema-only type used by +/// Light.PortableResults for OpenAPI documentation; the wire format is produced directly by +/// the runtime HTTP writers. +/// public class PortableAspNetCoreValidationProblemDetails : HttpValidationProblemDetails { /// - /// Gets or sets the optional Light.PortableResults-specific error details that correlate with - /// the inherited errors dictionary. + /// Optional Light.PortableResults-specific details that correlate with the inherited + /// errors dictionary. Each entry points back to a message in + /// errors[target] via its index property. /// public IReadOnlyList>? ErrorDetails { get; init; } /// - /// Gets or sets the top-level problem metadata. + /// Optional structured information about the validation failure as a whole, separate from + /// any individual error item. /// public TProblemMetadata Metadata { get; init; } = default!; } /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Convenience non-generic variant of -/// -/// that uses for both metadata type parameters. +/// RFC 9457 problem details response returned for validation failures when the API is +/// configured to serialize validation errors in the ASP.NET Core-compatible format. The +/// inherited errors property is a Dictionary<string, string[]> for +/// compatibility with clients that expect the ASP.NET Core default; the optional +/// errorDetails array supplies Light.PortableResults-specific information. /// +/// +/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat +/// is set to AspNetCoreCompatible. This is a schema-only type used by +/// Light.PortableResults for OpenAPI documentation; the wire format is produced directly by +/// the runtime HTTP writers. +/// public class PortableAspNetCoreValidationProblemDetails : PortableAspNetCoreValidationProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs index 32ac32b..0bcc2ca 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs @@ -1,67 +1,82 @@ namespace Light.PortableResults.AspNetCore.Shared; /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a single Light.PortableResults error item as it appears in rich -/// problem details responses with untyped metadata. +/// A single error entry describing why a request failed. Each error carries a human-readable +/// message, a stable machine-readable code, an optional target field, a category, and an +/// optional free-form metadata bag. /// +/// +/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format +/// is produced directly by the runtime HTTP writers. +/// public class PortableError { /// - /// Gets or sets the human-readable error message. + /// Human-readable description of what went wrong. /// public string Message { get; init; } = string.Empty; /// - /// Gets or sets the stable machine-readable error code. + /// Stable machine-readable identifier of this kind of error. Intended for callers that + /// branch on error types programmatically. /// public string? Code { get; init; } /// - /// Gets or sets the target (e.g. the offending input field) of the error. + /// The input field, property, or resource that the error refers to, if applicable. /// public string? Target { get; init; } /// - /// Gets or sets the error category. + /// Classification of the error (validation, conflict, authentication, and so on). Maps to + /// the HTTP status code that the API surfaces for the overall response. /// public ErrorCategory Category { get; init; } /// - /// Gets or sets the optional metadata associated with the error. + /// Additional structured information about the error, for example the lower and upper + /// boundary of a failing range check. The shape is error-specific. /// public object? Metadata { get; init; } } /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a single Light.PortableResults error item with strongly typed metadata. +/// A single error entry describing why a request failed. Each error carries a human-readable +/// message, a stable machine-readable code, an optional target field, a category, and an +/// optional structured metadata bag. /// -/// The type of the per-error metadata. +/// The shape of the per-error metadata. See for the non-generic variant. +/// +/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format +/// is produced directly by the runtime HTTP writers. +/// public class PortableError { /// - /// Gets or sets the human-readable error message. + /// Human-readable description of what went wrong. /// public string Message { get; init; } = string.Empty; /// - /// Gets or sets the stable machine-readable error code. + /// Stable machine-readable identifier of this kind of error. Intended for callers that + /// branch on error types programmatically. /// public string? Code { get; init; } /// - /// Gets or sets the target (e.g. the offending input field) of the error. + /// The input field, property, or resource that the error refers to, if applicable. /// public string? Target { get; init; } /// - /// Gets or sets the error category. + /// Classification of the error (validation, conflict, authentication, and so on). Maps to + /// the HTTP status code that the API surfaces for the overall response. /// public ErrorCategory Category { get; init; } /// - /// Gets or sets the metadata associated with the error. + /// Additional structured information about the error, for example the lower and upper + /// boundary of a failing range check. /// public TMetadata Metadata { get; init; } = default!; } diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs index e378b6c..8a26e0c 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs @@ -4,29 +4,39 @@ namespace Light.PortableResults.AspNetCore.Shared; /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a rich Light.PortableResults problem details response for non-validation failures, -/// with strongly typed per-error metadata and top-level problem metadata. +/// RFC 9457 problem details response returned for a non-validation failure (for example 401, +/// 403, 404, 409, or 500). Carries the standard problem details fields plus a list of +/// Light.PortableResults error items and optional top-level problem metadata. /// -/// The type of the metadata on each . -/// The type of the top-level problem metadata. +/// The shape of the per-error metadata on each errors entry. +/// The shape of the top-level metadata bag. +/// +/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format +/// is produced directly by the runtime HTTP writers. +/// public class PortableProblemDetails : ProblemDetails { /// - /// Gets or sets the collection of errors that caused the failure. + /// The error items that describe why the request failed. Typically contains a single entry + /// for non-validation failures. /// public IReadOnlyList> Errors { get; init; } = new List>(); /// - /// Gets or sets the top-level problem metadata. + /// Optional structured information about the failure as a whole, separate from any + /// individual error item. /// public TProblemMetadata Metadata { get; init; } = default!; } /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Convenience non-generic variant of -/// that uses for both metadata type parameters. +/// RFC 9457 problem details response returned for a non-validation failure (for example 401, +/// 403, 404, 409, or 500). Carries the standard problem details fields plus a list of +/// Light.PortableResults error items and optional top-level problem metadata. /// +/// +/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format +/// is produced directly by the runtime HTTP writers. +/// public class PortableProblemDetails : PortableProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs new file mode 100644 index 0000000..df226db --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs @@ -0,0 +1,78 @@ +using System; +using System.Text.Json.Serialization.Metadata; + +namespace Light.PortableResults.AspNetCore.Shared; + +/// +/// Naming conventions for the Light.PortableResults schema-only types so that +/// Microsoft.AspNetCore.OpenApi emits readable schema names such as +/// PortableError or PortableProblemDetails instead of the default +/// PortableErrorOfObject or PortableProblemDetailsOfObjectAndObject. +/// +/// +/// Register the convention when configuring OpenAPI, composing it with the default naming: +/// +/// builder.Services.AddOpenApi(options => +/// { +/// options.CreateSchemaReferenceId = type => +/// PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(type) ?? +/// OpenApiOptions.CreateDefaultSchemaReferenceId(type); +/// }); +/// +/// The helper only produces custom names for Light.PortableResults schema-only types whose +/// generic arguments are all . Any other type returns +/// and is expected to be handled by the caller's fallback. +/// +public static class PortableResultsOpenApiNamingConventions +{ + private const string SchemaNamespace = "Light.PortableResults.AspNetCore.Shared"; + + /// + /// Attempts to compute an OpenAPI schema reference id for a Light.PortableResults schema-only + /// type whose generic arguments are all . For + /// PortableError<object> this returns "PortableError", for + /// PortableProblemDetails<object, object> this returns + /// "PortableProblemDetails", and so on. + /// + /// The JSON type info for which the OpenAPI schema reference id is built. + /// + /// A custom schema reference id for recognized Light.PortableResults schema-only types, + /// or when the default naming should be used. + /// + /// Thrown when is null. + public static string? TryCreateSchemaReferenceId(JsonTypeInfo typeInfo) + { + if (typeInfo is null) + { + throw new ArgumentNullException(nameof(typeInfo)); + } + + return TryGetSimpleNameForAllObjectGenericArgs(typeInfo.Type); + } + + private static string? TryGetSimpleNameForAllObjectGenericArgs(Type type) + { + if (type.Namespace != SchemaNamespace) + { + return null; + } + + if (!type.IsGenericType || type.IsGenericTypeDefinition) + { + return null; + } + + var genericArguments = type.GetGenericArguments(); + for (var i = 0; i < genericArguments.Length; i++) + { + if (genericArguments[i] != typeof(object)) + { + return null; + } + } + + var name = type.Name; + var tickIndex = name.IndexOf('`'); + return tickIndex < 0 ? name : name.Substring(0, tickIndex); + } +} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs index a03c058..7fb18ca 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs @@ -4,32 +4,42 @@ namespace Light.PortableResults.AspNetCore.Shared; /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a rich Light.PortableResults validation problem details response that documents the -/// errors property as an array of Light.PortableResults-style error objects. -/// Use this type when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat -/// is set to Rich. +/// RFC 9457 problem details response returned for validation failures when the API is +/// configured to serialize validation errors in the rich Light.PortableResults format. The +/// errors property is a structured array of error items rather than the ASP.NET Core +/// default Dictionary<string, string[]>. /// -/// The type of the metadata on each . -/// The type of the top-level problem metadata. +/// The shape of the per-error metadata on each errors entry. +/// The shape of the top-level metadata bag. +/// +/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat +/// is set to Rich. This is a schema-only type used by Light.PortableResults for OpenAPI +/// documentation; the wire format is produced directly by the runtime HTTP writers. +/// public class PortableRichValidationProblemDetails : ProblemDetails { /// - /// Gets or sets the collection of validation errors. + /// The validation errors that caused the request to be rejected. /// public IReadOnlyList> Errors { get; init; } = new List>(); /// - /// Gets or sets the top-level problem metadata. + /// Optional structured information about the validation failure as a whole, separate from + /// any individual error item. /// public TProblemMetadata Metadata { get; init; } = default!; } /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Convenience non-generic variant of -/// -/// that uses for both metadata type parameters. +/// RFC 9457 problem details response returned for validation failures when the API is +/// configured to serialize validation errors in the rich Light.PortableResults format. The +/// errors property is a structured array of error items rather than the ASP.NET Core +/// default Dictionary<string, string[]>. /// +/// +/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat +/// is set to Rich. This is a schema-only type used by Light.PortableResults for OpenAPI +/// documentation; the wire format is produced directly by the runtime HTTP writers. +/// public class PortableRichValidationProblemDetails : PortableRichValidationProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs index d668ef1..1b8475d 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs @@ -1,24 +1,30 @@ namespace Light.PortableResults.AspNetCore.Shared; /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a successful response body that contains both a value and metadata. -/// Use this helper only for wrapped success responses that serialize metadata in the body. -/// For plain success bodies, use the standard ASP.NET Core -/// OpenAPI metadata APIs such as Produces<TValue> or -/// ProducesResponseTypeAttribute<TValue> instead. +/// Successful response body that wraps a primary together with +/// a bag of , for endpoints that opt into returning metadata +/// alongside the value. /// -/// The type of the success value. -/// The type of the success metadata. +/// The shape of the main payload. +/// The shape of the metadata accompanying the payload. +/// +/// Use this schema only when the runtime is configured to emit the { value, metadata } +/// envelope (see MetadataSerializationMode.Always). For plain payload responses, use the +/// standard ASP.NET Core OpenAPI helpers such as Produces<TValue> or +/// ProducesResponseTypeAttribute<TValue> instead. This is a schema-only type used +/// by Light.PortableResults for OpenAPI documentation; the wire format is produced directly by +/// the runtime HTTP writers. +/// public class PortableSuccessResponse { /// - /// Gets or sets the result value. + /// The primary payload returned by the operation. /// public TValue Value { get; init; } = default!; /// - /// Gets or sets the metadata associated with the value. + /// Additional structured information associated with the payload, for example paging + /// details or aggregate counts. /// public TMetadata Metadata { get; init; } = default!; } diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs index 5fa6be1..2fa2c3a 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs +++ b/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs @@ -1,70 +1,88 @@ namespace Light.PortableResults.AspNetCore.Shared; /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a single entry in the errorDetails array of an -/// ASP.NET Core-compatible validation problem details response, with untyped metadata. +/// A Light.PortableResults-specific supplement for a single validation error inside an +/// ASP.NET Core-compatible problem details response. Correlates to a message in the standard +/// errors dictionary and adds machine-readable information such as an error code, +/// category, and metadata bag. /// +/// +/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format +/// is produced directly by the runtime HTTP writers. +/// public class PortableValidationErrorDetail { /// - /// Gets or sets the target (e.g. the offending input field) the error detail refers to. + /// The input field, property, or resource this error detail refers to. Matches the key + /// in the inherited errors dictionary of the enclosing problem details response. /// public string Target { get; init; } = string.Empty; /// - /// Gets or sets the zero-based position of the corresponding error message within - /// the errors[target] array for the same target. + /// Zero-based position of the corresponding error message within errors[target] + /// for the same target, so errorDetails entries can be correlated back to the + /// original message. /// public int Index { get; init; } /// - /// Gets or sets the stable machine-readable error code. + /// Stable machine-readable identifier of this kind of error. Intended for callers that + /// branch on error types programmatically. /// public string? Code { get; init; } /// - /// Gets or sets the optional error category. + /// Optional classification of the error (validation, conflict, authentication, and so on). /// public ErrorCategory? Category { get; init; } /// - /// Gets or sets the optional metadata associated with the error detail. + /// Additional structured information about the error, for example the lower and upper + /// boundary of a failing range check. /// public object? Metadata { get; init; } } /// -/// Schema-only type for OpenAPI documentation. Not used at runtime. -/// Represents a single entry in the errorDetails array of an -/// ASP.NET Core-compatible validation problem details response, with strongly typed metadata. +/// A Light.PortableResults-specific supplement for a single validation error inside an +/// ASP.NET Core-compatible problem details response. Correlates to a message in the standard +/// errors dictionary and adds machine-readable information such as an error code, +/// category, and metadata bag. /// -/// The type of the per-error-detail metadata. +/// The shape of the per-error-detail metadata. See for the non-generic variant. +/// +/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format +/// is produced directly by the runtime HTTP writers. +/// public class PortableValidationErrorDetail { /// - /// Gets or sets the target (e.g. the offending input field) the error detail refers to. + /// The input field, property, or resource this error detail refers to. Matches the key + /// in the inherited errors dictionary of the enclosing problem details response. /// public string Target { get; init; } = string.Empty; /// - /// Gets or sets the zero-based position of the corresponding error message within - /// the errors[target] array for the same target. + /// Zero-based position of the corresponding error message within errors[target] + /// for the same target, so errorDetails entries can be correlated back to the + /// original message. /// public int Index { get; init; } /// - /// Gets or sets the stable machine-readable error code. + /// Stable machine-readable identifier of this kind of error. Intended for callers that + /// branch on error types programmatically. /// public string? Code { get; init; } /// - /// Gets or sets the optional error category. + /// Optional classification of the error (validation, conflict, authentication, and so on). /// public ErrorCategory? Category { get; init; } /// - /// Gets or sets the metadata associated with the error detail. + /// Additional structured information about the error, for example the lower and upper + /// boundary of a failing range check. /// public TMetadata Metadata { get; init; } = default!; } diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs new file mode 100644 index 0000000..4f24cdc --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using FluentAssertions; +using Xunit; + +namespace Light.PortableResults.AspNetCore.Shared.Tests; + +public sealed class PortableResultsOpenApiNamingConventionsTests +{ + public static TheoryData AllObjectGenericArgsCases { get; } = + new () + { + { typeof(PortableError), "PortableError" }, + { typeof(PortableValidationErrorDetail), "PortableValidationErrorDetail" }, + { typeof(PortableSuccessResponse), "PortableSuccessResponse" }, + { typeof(PortableProblemDetails), "PortableProblemDetails" }, + { + typeof(PortableRichValidationProblemDetails), + "PortableRichValidationProblemDetails" + }, + { + typeof(PortableAspNetCoreValidationProblemDetails), + "PortableAspNetCoreValidationProblemDetails" + } + }; + + public static TheoryData FallthroughCases { get; } = + new () + { + // Non-object generic args: caller's default naming should apply. + typeof(PortableError), + typeof(PortableProblemDetails), + typeof(PortableProblemDetails), + typeof(PortableSuccessResponse), + // Non-generic schema types stay with their default name. + typeof(PortableError), + typeof(PortableProblemDetails), + // Unrelated types are never handled by the helper. + typeof(string), + typeof(SomeMetadata) + }; + + [Theory] + [MemberData(nameof(AllObjectGenericArgsCases))] + public void TryCreateSchemaReferenceId_ReturnsSimpleNameWhenAllGenericArgsAreObject( + Type type, string expected + ) + { + var typeInfo = JsonTypeInfo.CreateJsonTypeInfo(type, JsonSerializerOptions.Default); + + var result = PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(typeInfo); + + result.Should().Be(expected); + } + + [Theory] + [MemberData(nameof(FallthroughCases))] + public void TryCreateSchemaReferenceId_ReturnsNullToSignalFallthrough(Type type) + { + var typeInfo = JsonTypeInfo.CreateJsonTypeInfo(type, JsonSerializerOptions.Default); + + var result = PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(typeInfo); + + result.Should().BeNull(); + } + + [Fact] + public void TryCreateSchemaReferenceId_ThrowsWhenTypeInfoIsNull() + { + Action act = () => PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(null!); + + act.Should().Throw().WithParameterName("typeInfo"); + } + + public sealed class SomeMetadata + { + public string? Name { get; init; } + } +} From d08c22ef60a9f31dc1bcddc6a79a725a4a87d85e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 19 Apr 2026 19:21:25 +0200 Subject: [PATCH 08/67] chore: rename initial OpenAPI Support plan Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 2 +- ai-plans/{0040-openapi-support.md => 0040-0-openapi-support.md} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename ai-plans/{0040-openapi-support.md => 0040-0-openapi-support.md} (100%) diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 6d0c30d..e695399 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -49,7 +49,7 @@ - + diff --git a/ai-plans/0040-openapi-support.md b/ai-plans/0040-0-openapi-support.md similarity index 100% rename from ai-plans/0040-openapi-support.md rename to ai-plans/0040-0-openapi-support.md From 19c96ab545c3e0c9c3e28b8fba4500ca5b5bcfac Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 19 Apr 2026 22:31:15 +0200 Subject: [PATCH 09/67] chore: add OpenAPI support redesign plan Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 2 + ai-plans/0040-1-openapi-redesign.md | 248 ++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 ai-plans/0040-1-openapi-redesign.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index e695399..4cd67a0 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -50,6 +50,8 @@ + + diff --git a/ai-plans/0040-1-openapi-redesign.md b/ai-plans/0040-1-openapi-redesign.md new file mode 100644 index 0000000..f09c204 --- /dev/null +++ b/ai-plans/0040-1-openapi-redesign.md @@ -0,0 +1,248 @@ +# OpenAPI Support Redesign + +## Rationale + +Plan `0040-0-openapi-support.md` added OpenAPI support through schema-only CLR surrogate types (`PortableError`, `PortableProblemDetails`, and so on). The generic type parameters pollute the emitted OpenAPI schema names, which forced a workaround (`PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId`) and a parallel `` alias hierarchy purely for naming. The workaround only handles the `` case; strongly typed metadata still produces names such as `PortableProblemDetailsOfMyErrorMetaAndMyProblemMeta`. The non-generic `PortableError` and `PortableValidationErrorDetail` classes are not reachable from any helper and duplicate the generic surface without adding value. Finally, the typed metadata generics promise a schema shape the runtime cannot honor: the runtime always serializes `MetadataObject` via `Utf8JsonWriter.WriteMetadataObject` (see `src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs`), not the caller's CLR type. + +This redesign replaces the CLR-surrogate approach with a library-authored OpenAPI schema catalog and an `IOpenApiDocumentTransformer`. The library owns the envelope schemas directly (five canonical envelope components plus a shared `ErrorCategory` enum component) and injects them into the `OpenApiDocument`. Endpoint helpers and MVC attributes become thin markers that the transformer reads to emit operation responses. Per-error-code metadata contracts are registered once in DI and opted into per endpoint, with an inline escape hatch. + +The entire OpenAPI-facing surface ships in a new dedicated package `Light.PortableResults.AspNetCore.OpenApi`. It depends on `Microsoft.AspNetCore.OpenApi` (which is not part of the `Microsoft.AspNetCore.App` shared framework and therefore must be referenced as a NuGet package) and project-references `Light.PortableResults.AspNetCore.Shared`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` do **not** take on a dependency on `Microsoft.AspNetCore.OpenApi` — consumers who want OpenAPI support opt in by also referencing the new package, so applications that never touch OpenAPI do not pay the transitive cost. + +The redesign explicitly targets `Microsoft.AspNetCore.OpenApi` only. Swashbuckle / NSwag interop is a non-goal. + +This plan supersedes the OpenAPI portions of `0040-0-openapi-support.md`. The breaking rename of `WrappedResponse` to `PortableSuccessResponse<...>` that plan already landed is not reverted; the type is simply removed along with the rest of the schema-only surface. This is intentionally a breaking change to the OpenAPI-facing public surface of the ASP.NET Core packages; the root `AGENTS.md` explicitly permits breaking changes while the library is pre-stable. + +## Acceptance Criteria + +- [ ] All schema-only CLR types introduced by `0040-0-openapi-support.md` are deleted: `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableSuccessResponse`. +- [ ] `PortableResultsOpenApiNamingConventions` is deleted together with its tests. +- [ ] All two-generic endpoint helpers on `PortableResultsEndpointExtensions` and all two-generic MVC attributes are deleted. The helper/attribute split between `Rich` and `AspNetCoreCompatible` validation problems is collapsed into a single helper/attribute. +- [ ] The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` no longer expose any OpenAPI helper or attribute surface at all. Concretely, the entire `PortableResultsEndpointExtensions` class is deleted from `Light.PortableResults.AspNetCore.MinimalApis` (including every non-generic helper such as `ProducesPortableProblem`, `ProducesPortableRichValidationProblem`, and `ProducesPortableAspNetCoreValidationProblem`, not only the two-generic overloads), and `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, `ProducesPortableRichValidationProblemAttribute`, and `ProducesPortableAspNetCoreValidationProblemAttribute` are deleted from `Light.PortableResults.AspNetCore.Mvc`. The replacements live exclusively in the new `Light.PortableResults.AspNetCore.OpenApi` package so there is a single public OpenAPI surface across the solution. +- [ ] A new project `Light.PortableResults.AspNetCore.OpenApi` is added to the solution. It targets .NET 10, sets `true`, project-references `Light.PortableResults.AspNetCore.Shared`, carries a ``, and takes on the NuGet `` at the version already pinned in `Directory.Packages.props`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` do not gain this package reference. +- [ ] `Light.PortableResults.AspNetCore.OpenApi` contains a library-authored OpenAPI schema catalog class named `PortableResultsOpenApiSchemas` that writes exactly five canonical envelope components into `OpenApiDocument.Components.Schemas` under the exact ids `PortableError`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`, plus a supporting `ErrorCategory` enum component (six schema components total). The `metadata`, `errorDetails[*].metadata`, and `errors[*].metadata` slots are declared as open objects (`type: object, additionalProperties: true`) to match what `MetadataExtensions.WriteMetadataObject` actually emits. Success envelopes are not part of the canonical catalog; they are synthesized per operation by the transformer because they only take a stable shape in the context of a specific `TValue`. +- [ ] `Light.PortableResults.AspNetCore.OpenApi` contains an `IOpenApiDocumentTransformer` implementation named `PortableResultsOpenApiDocumentTransformer` that (a) installs the canonical catalog once per document, (b) resolves the effective validation format from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` or the per-endpoint override, and (c) synthesizes any operation-specific derived schemas required by the markers attached to each endpoint. +- [ ] `Light.PortableResults.AspNetCore.OpenApi` exposes a single opt-in entry point `AddPortableResultsOpenApi(this IServiceCollection services)` that registers `PortableResultsOpenApiDocumentTransformer` and its `ConfigureAll` hook idempotently (using `TryAddSingleton` for the transformer plus a private gate service so repeated calls register the configure-options callback exactly once). Consumers who want OpenAPI support call this alongside `AddPortableResultsForMinimalApis` and/or `AddPortableResultsForMvc`. `AddPortableResultsForMinimalApis` and `AddPortableResultsForMvc` do **not** transitively call `AddPortableResultsOpenApi`, so applications that never touch OpenAPI are unaffected. Callers do not need to configure `OpenApiOptions.CreateSchemaReferenceId`. +- [ ] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly the following `RouteHandlerBuilder` extension methods on `PortableResultsOpenApiRouteHandlerBuilderExtensions`, where `TValue` is the only generic on the public helper surface: + - `ProducesPortableSuccessResponse(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, string contentType = "application/json", Action? configure = null)` + - `ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json", Action? configure = null)` + - `ProducesPortableValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json", Action? configure = null)` +- [ ] `PortableSuccessResponseOpenApiBuilder` exposes `UseMetadataSerializationMode(MetadataSerializationMode mode)` as a per-endpoint static override, mirroring the existing `UseFormat(ValidationProblemSerializationFormat)` override on `PortableValidationProblemOpenApiBuilder`. The documented schema is selected by the transformer from the resolved mode, so callers can either take the DI default or override it per endpoint for documentation purposes. It remains the caller's responsibility to align any runtime `overrideOptions` passed to `LightResult` / `LightActionResult` with the documented mode. +- [ ] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly three attributes: `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, and `ProducesPortableValidationProblemAttribute`. Each is `sealed` and works for both MVC controllers (applied to an action method) and Minimal APIs (the corresponding helper constructs and attaches an instance via `RouteHandlerBuilder.WithMetadata`). They sit in a three-level hierarchy designed so that every public knob is applicable to the type it is declared on (no silent ignores): a public abstract base `PortableOpenApiResponseAttributeBase : Attribute` carries only the truly shared knobs (`Kind`, `StatusCode`, `ContentType`, `TopLevelMetadataType`); a public abstract intermediate `PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase` adds the error-list knobs (`ErrorCodes`, `InlineErrorMetadataCodes`, `InlineErrorMetadataTypes`); the three sealed attributes add kind-specific knobs directly — the success attribute adds `ValueType` (set in its constructor from `typeof(TValue)`) and `MetadataSerializationMode`, the problem attribute adds nothing beyond the error base, the validation attribute adds `Format`. Each sealed attribute exposes a constructor accepting `(int statusCode, string contentType)` with defaults matching its Minimal APIs counterpart (`200` / `application/json` for the success attribute, `500` / `application/problem+json` for the problem attribute, `400` / `application/problem+json` for the validation attribute), so call sites read naturally as `[ProducesPortableProblem(404)]` rather than `[ProducesPortableProblem(StatusCode = 404)]`. The base is intentionally not derived from `ProducesResponseTypeAttribute` because the transformer owns schema selection end-to-end; a consequence is that MVC filters and analyzers that enumerate `ProducesResponseTypeAttribute` (for example the default `ApiExplorer` content-negotiation behavior) will not see Light.PortableResults responses, and the document transformer is the single source of truth for these operations. MVC attribute instances enter endpoint metadata through the standard MVC endpoint-routing pipeline (attributes on a controller action are added to `ActionDescriptor.EndpointMetadata` automatically), so the attributes do not need to implement `IEndpointMetadataProvider`. +- [ ] A global error-code metadata registry is exposed through the extension method `ConfigureErrorMetadataContracts(this IServiceCollection services, Action configure)` declared in `Light.PortableResults.AspNetCore.OpenApi`. `PortableErrorMetadataContractsBuilder` exposes `ForCode(string code)` and `ForCode(string code, Type metadataType)` registration methods. The registrations are stored in a singleton service `IPortableErrorMetadataContractRegistry` with an immutable `IReadOnlyDictionary Contracts` property. Registered codes are synthesized into `PortableError__` and `PortableValidationErrorDetail__` schema components once per document (see the sanitization criterion below); endpoints opt into specific codes via `WithErrorCodes(params string[])`. When `WithErrorCodes` references a code that is not present in `IPortableErrorMetadataContractRegistry.Contracts`, the transformer throws `InvalidOperationException` at document generation with a message that names the unregistered code and suggests either registering it through `ConfigureErrorMetadataContracts` or using the inline `WithErrorMetadata` escape hatch. Inline escape hatches `WithErrorMetadata(string code, Type metadataType)` and `WithErrorMetadata(string code)` are available on the problem and validation-problem endpoint builders for codes that are not globally registered. +- [ ] `ConfigureErrorMetadataContracts` is implemented on top of the standard .NET options pipeline: it wraps the caller's `Action` in a `services.Configure(...)` registration (where `PortableErrorMetadataContractsOptions` is a small public options type owning a single `Builder` property), and registers `IPortableErrorMetadataContractRegistry` via `TryAddSingleton` with a factory that materializes the immutable registry from `IOptions.Value.Builder`. This gives additive composition for free: multiple invocations (for example from separate feature modules during composition-root setup) each register another `IConfigureOptions` that runs in registration order against the same lazily-created options instance. Registering the same raw code twice with the same `Type` is an idempotent no-op. Registering the same raw code twice with two different `Type`s throws `InvalidOperationException` with a message naming the raw code and both conflicting types; the throw fires either inside `PortableErrorMetadataContractsBuilder.ForCode` (when the conflict is observable to the builder at configure time) or at registry materialization (when two independent configure callbacks contribute conflicting entries). +- [ ] Per-endpoint metadata narrowing is expressed in the emitted OpenAPI document using `allOf` to extend a canonical envelope and `anyOf + discriminator` on the error `code` property to narrow `errors[*]` (rich format) or `errorDetails[*]` (asp.net-core-compatible format). The transformer emits an explicit `discriminator.mapping` whose keys are the raw code strings as they appear on the wire and whose values are JSON-Pointer-escaped `$ref`s to the synthesized variants (for example `VersionMismatch: '#/components/schemas/PortableError__VersionMismatch'`), because implicit discriminator resolution matches on bare component name and our synthesized component ids are `PortableError__`. A fallback `$ref` to the base `PortableError` / `PortableValidationErrorDetail` schema is always included as the last branch of the `anyOf` so that undocumented codes remain valid; `anyOf` is used instead of `oneOf` because every narrowed variant is an `allOf` restriction of the base schema and would therefore also match the base, which violates `oneOf` semantics. +- [ ] The transformer applies a deterministic sanitization scheme to every error code used in a component id: characters outside `[A-Za-z0-9_]` are replaced with `_`. Collisions after sanitization are rejected at the earliest possible moment: `ConfigureErrorMetadataContracts` throws `InvalidOperationException` at registration time when two distinct globally registered codes sanitize to the same id, and the transformer throws `InvalidOperationException` at document generation time when two distinct inline `WithErrorMetadata` codes on the same `(operation, StatusCode, ContentType)` triple sanitize to the same suffix; both messages name the conflicting raw codes. The discriminator `mapping` keys use the unsanitized raw code (matching what the runtime writes to the `code` property), and the discriminator `mapping` values and operation-level `$ref`s apply JSON Pointer escaping per RFC 6901 (`~` → `~0`, `/` → `~1`) defensively. Sanitization applies identically to `PortableError__`, `PortableValidationErrorDetail__`, and operation-scoped inline variants. +- [ ] The runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, and the JSON writers in `Light.PortableResults` is unchanged. +- [ ] The transformer resolves the target OpenAPI spec version from a single source of truth: `OpenApiOptions.OpenApiVersion` for the current document, obtained via `context.ApplicationServices.GetRequiredService>().Get(context.DocumentName)`. It emits discriminator narrowing using schema-level `const` when the resolved version is OpenAPI 3.1 or later, and falls back to `enum: []` for OpenAPI 3.0. Generated schemas are spec-valid against both versions. +- [ ] When multiple `PortableOpenApiResponseAttributeBase` instances share the same `(StatusCode, ContentType)` key on the same operation, the transformer treats them as distinct contributing schemas for the same HTTP response and merges them into a single `OpenApiResponse` whose media-type schema is an `anyOf` over the contributing envelopes, so common designs such as documenting both a `ProducesPortableProblemAttribute(400)` and a `ProducesPortableValidationProblemAttribute(400)` on the same endpoint at `application/problem+json` produce one response entry with a unioned schema. The transformer still throws `InvalidOperationException` at document generation time when more than one marker of the same `Kind` is attached to the same operation for the same `(StatusCode, ContentType)` key (for example two `ProducesPortableProblemAttribute`s with identical status and content type), because that is a genuine ambiguity about which narrowing to emit. It also throws `InvalidOperationException` when an attribute instance has both `InlineErrorMetadataCodes` and `InlineErrorMetadataTypes` set to non-null arrays of different lengths; the exception message includes both observed lengths so the caller can realign them. +- [ ] The `PackageReleaseNotes` section of `Light.PortableResults.AspNetCore.MinimalApis.csproj` and `Light.PortableResults.AspNetCore.Mvc.csproj` is updated to call out the removal of the schema-only CLR types and the helper/attribute collapse, and to point consumers at the new `Light.PortableResults.AspNetCore.OpenApi` package for OpenAPI integration. `Light.PortableResults.AspNetCore.OpenApi.csproj` carries its own `PackageReleaseNotes` introducing the package, its opt-in `AddPortableResultsOpenApi` entry point, the canonical schema catalog, the three helpers, the three attributes, and the error-metadata registry. +- [ ] Automated tests cover the document transformer end-to-end: canonical catalog emission, each helper/attribute's effect on the generated document, global error-code registry integration, inline escape hatch, per-endpoint format override, success-response metadata narrowing, and the fallback `$ref` for undocumented codes. +- [ ] The `NativeAotMovieRating` sample is updated to the new public API (adds a `ProjectReference` to `Light.PortableResults.AspNetCore.OpenApi`, calls `AddPortableResultsOpenApi()`, uses the new helpers) and no longer wires `OpenApiOptions.CreateSchemaReferenceId`. +- [ ] The new OpenAPI surface remains NativeAOT-compatible. The `NativeAotMovieRating` sample continues to build and run under `PublishAot=true`, and the document transformer uses only APIs compatible with the trimmer and AOT analyzer (no `Type.MakeGenericType`, no dynamic assembly emit, no reflection over handler parameters; all generic dispatch happens through the existing `GetOrCreateSchemaAsync` API and through attribute instances supplied at compile time). +- [ ] `README.md` is updated: the OpenAPI section reflects the new public surface (new `Light.PortableResults.AspNetCore.OpenApi` package, opt-in `AddPortableResultsOpenApi()`, three helpers, three attributes, DI-level `ConfigureErrorMetadataContracts`, per-endpoint format override), and all references to the deleted schema-only CLR types, the naming convention, and the `Rich` vs `AspNetCoreCompatible` helper split are removed. + +## Technical Details + +### Ownership Model + +- **Envelope schemas are library-owned.** They are authored once, in OpenAPI notation, in `Light.PortableResults.AspNetCore.OpenApi`. They do not exist as CLR types. +- **Metadata content is caller-owned.** By default metadata slots are declared as open objects. The endpoint builder is the single place where narrowing is expressed per endpoint: it narrows the top-level `metadata` schema via `WithMetadata`, opts into globally registered per-code contracts via `WithErrorCodes`, and overrides one-off codes inline via `WithErrorMetadata`. The global `ConfigureErrorMetadataContracts` registry is a complementary mechanism that stores the per-code metadata contracts the builder references, not an alternative to it. Typical apps use both: register each stable error code once in DI, then opt the relevant codes into each failure response on the endpoint. +- **Validation format is per-endpoint with a DI default.** The runtime already supports this through `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` plus the per-call override path in `HttpExtensions.ResolvePortableResultsHttpWriteOptions`. The OpenAPI helper mirrors this: if no `format` is passed, the configured app default is used. +- **Documentation overrides are declarative and static, runtime overrides are per-call.** Both `UseMetadataSerializationMode(...)` and `UseFormat(...)` attach endpoint metadata that the transformer reads at document generation time. Runtime handlers use a separate override path (`LightResult` / `LightActionResult` constructors accept `PortableResultsHttpWriteOptions? overrideOptions`). The library cannot observe runtime overrides from a static transformer, so callers who override runtime options per endpoint must pass a matching declarative override to the OpenAPI helper/attribute to keep the documented shape aligned with the wire. A future plan may unify these two paths by having `HttpContext.ResolvePortableResultsHttpWriteOptions` consult endpoint metadata as an additional fallback step; that runtime change is explicitly out of scope here. +- **Success-response shape is mode-aware.** The runtime produces two distinct body shapes for `LightResult` (see the `IsValid` branch in `HttpResultForWritingJsonConverter`): a bare `T` under `MetadataSerializationMode.ErrorsOnly`, and a wrapped `{ value: T, metadata?: object }` under `MetadataSerializationMode.Always` via `SerializeValueAndMetadata`, where `metadata` is only written when any metadata value is annotated `SerializeInHttpResponseBody`. `ProducesPortableSuccessResponse` is faithful to both modes: the transformer resolves the effective mode from `attr.MetadataSerializationMode ?? options.Value.MetadataSerializationMode`, calls `context.GetOrCreateSchemaAsync(attr.ValueType!)` to obtain the value-type schema (which may be inline for primitives and collections, or a reference for complex types), and either installs that schema directly on the response (under `ErrorsOnly` with no narrowing) or wraps it in a per-operation envelope component registered via `document.AddComponent` (under `Always` or whenever `TopLevelMetadataType` is set). Non-generic `LightResult` / `LightActionResult` success responses are out of scope for this helper; callers document them with plain ASP.NET Core helpers (`Produces()`, `ProducesResponseType()`, or status-only responses). + +### Canonical Schema Catalog + +A single static class `PortableResultsOpenApiSchemas` in `Light.PortableResults.AspNetCore.OpenApi` produces the canonical schemas and installs them into `OpenApiDocument.Components.Schemas`. Its only public method is `InstallInto(OpenApiDocument document)`, which is idempotent (keyed by schema component id) and initializes `document.Components` and `document.Components.Schemas` if either is null. Tests assert on the installed components by calling `InstallInto` on a fresh `OpenApiDocument` and inspecting `document.Components.Schemas`. Each schema is authored directly as `OpenApiSchema` objects from `Microsoft.OpenApi.Models`. + +Schema shapes mirror what the runtime actually writes: + +- `PortableError`: `message` (string, required), `code` (string, nullable), `target` (string, nullable), `category` (`$ref: ErrorCategory`), `metadata` (open object, nullable). +- `PortableValidationErrorDetail`: `target` (string, required), `index` (integer, required), `code` (string, nullable), `category` (`$ref: ErrorCategory`, nullable), `metadata` (open object, nullable). +- `PortableProblemDetails`: extends RFC 9457 Problem Details with `errors` (array of `PortableError`) and `metadata` (open object, nullable). +- `PortableRichValidationProblemDetails`: same shape as `PortableProblemDetails` but a distinct schema component so generated client code can distinguish validation failures. +- `PortableAspNetCoreValidationProblemDetails`: extends `HttpValidationProblemDetails` with optional `errorDetails` (array of `PortableValidationErrorDetail`) and `metadata` (open object, nullable). + +Success responses are intentionally absent from the catalog. They only take a stable shape in the context of a specific `TValue`, and the transformer synthesizes each one per operation via `document.AddComponent` (where `document` is the `OpenApiDocument` parameter passed to `TransformAsync`). The shape of every synthesized success envelope is `{ value: , metadata: open object (nullable) }`. + +The `ErrorCategory` enum is also emitted as a schema component once under the id `ErrorCategory`, reused by all envelopes. + +### Endpoint Metadata Attributes + +There is no separate marker POCO. The three sealed attribute types are themselves the endpoint-metadata entries the transformer reads from `apiDescription.ActionDescriptor.EndpointMetadata`. They sit in a three-level hierarchy chosen so that every public knob is valid on the type it is declared on — there are no silent-ignore properties on any public attribute. + +```csharp +public abstract class PortableOpenApiResponseAttributeBase : Attribute +{ + protected PortableOpenApiResponseAttributeBase( + PortableOpenApiResponseKind kind, + int statusCode, + string contentType) + { + Kind = kind; + StatusCode = statusCode; + ContentType = contentType; + } + + public PortableOpenApiResponseKind Kind { get; } + public int StatusCode { get; set; } + public string ContentType { get; set; } + public Type? TopLevelMetadataType { get; set; } +} + +public abstract class PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase +{ + protected PortableOpenApiErrorResponseAttributeBase( + PortableOpenApiResponseKind kind, + int statusCode, + string contentType) + : base(kind, statusCode, contentType) { } + + public string[]? ErrorCodes { get; set; } + public string[]? InlineErrorMetadataCodes { get; set; } + public Type[]? InlineErrorMetadataTypes { get; set; } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableSuccessResponseAttribute : PortableOpenApiResponseAttributeBase +{ + public ProducesPortableSuccessResponseAttribute( + int statusCode = StatusCodes.Status200OK, + string contentType = "application/json") + : base(PortableOpenApiResponseKind.SuccessResponse, statusCode, contentType) + { + ValueType = typeof(TValue); + } + + public Type ValueType { get; } + public MetadataSerializationMode? MetadataSerializationMode { get; set; } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableProblemAttribute : PortableOpenApiErrorResponseAttributeBase +{ + public ProducesPortableProblemAttribute( + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json") + : base(PortableOpenApiResponseKind.Problem, statusCode, contentType) { } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableValidationProblemAttribute : PortableOpenApiErrorResponseAttributeBase +{ + public ProducesPortableValidationProblemAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json") + : base(PortableOpenApiResponseKind.ValidationProblem, statusCode, contentType) { } + + public ValidationProblemSerializationFormat? Format { get; set; } +} +``` + +`PortableOpenApiResponseKind` is an enum with values `SuccessResponse`, `Problem`, `ValidationProblem`. `AllowMultiple = true` lets a single operation declare several response contracts per kind (for example two distinct `[ProducesPortableProblem]` status codes, or a problem plus a validation problem at the same status code — see the merge rule in the *Document Transformer* section). + +Minimal APIs helpers construct a concrete attribute instance, pass it into the corresponding configuration builder (which only exposes members that map onto settable properties actually present on that concrete attribute), and then call `RouteHandlerBuilder.WithMetadata(attributeInstance)`. MVC attribute instances flow into `ActionDescriptor.EndpointMetadata` through the standard MVC endpoint-routing pipeline. Both paths converge on the same metadata hierarchy, and the transformer reads them uniformly via `apiDescription.ActionDescriptor.EndpointMetadata.OfType()`, then branches on concrete type for kind-specific logic. No reflection over handler parameters is needed, and the design is AOT-friendly. + +### Document Transformer + +`PortableResultsOpenApiDocumentTransformer` (in `Light.PortableResults.AspNetCore.OpenApi`) is a singleton service implementing `IOpenApiDocumentTransformer`. Its constructor takes `IOptions` and `IPortableErrorMetadataContractRegistry`. The transformer holds no mutable instance state between invocations: all per-document state lives on the passed `OpenApiDocument` and transformer context, so it is safe to register as a singleton and to run concurrently across multiple OpenAPI documents. Its `TransformAsync` signature is `TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)`: every component registration writes to the `document` parameter directly (for example `document.AddComponent(name, schema)`), because `OpenApiDocumentTransformerContext` does not expose a `Document` property — it provides only `DocumentName`, `DescriptionGroups`, `ApplicationServices`, and the `GetOrCreateSchemaAsync` method. The target OpenAPI spec version is resolved per invocation from `context.ApplicationServices.GetRequiredService>().Get(context.DocumentName).OpenApiVersion`, so a single source of truth (`OpenApiOptions.OpenApiVersion`) drives all spec-version-dependent branches. `TransformAsync`: + +1. On first invocation for a given document, calls `PortableResultsOpenApiSchemas.InstallInto(document)` (idempotent: checked by schema component id). +2. For each registered entry in `IPortableErrorMetadataContractRegistry.Contracts`, synthesizes the global `PortableError__` and `PortableValidationErrorDetail__` schema components once, applying the error-code sanitization rule described in the Acceptance Criteria. +3. Iterates the `ApiDescription` instances exposed through `context.DescriptionGroups`, reads `PortableOpenApiResponseAttributeBase` entries via `apiDescription.ActionDescriptor.EndpointMetadata.OfType()` (working uniformly for Minimal APIs and MVC), and locates the matching `OpenApiOperation` by translating the `ApiDescription` into `OpenApiDocument.Paths` keys: the path key is `"/" + apiDescription.RelativePath` when `RelativePath` does not already start with `/`, and the operation key is obtained by parsing `apiDescription.HttpMethod` into `Microsoft.OpenApi.Models.OperationType` (case-insensitive `Enum.Parse`). The resolved `OpenApiOperation` has its `Responses` collection mutated in place. The transformer groups the attributes by `(StatusCode, ContentType)`: if any group contains more than one attribute of the same `Kind`, it throws `InvalidOperationException` with a message naming the status, content type, and kind. Groups that contain multiple attributes of different `Kind`s are accepted and merged in step 4 so that documenting both a problem and a validation problem at the same `(StatusCode, ContentType)` — a common cloud-API shape — produces one `OpenApiResponse` with a unioned schema. +4. For each `(StatusCode, ContentType)` group on each operation, builds a list of contributing schemas — one per attribute in the group — and then attaches them to the `OpenApiResponse` for that status/media type: + - If the group has one contributing schema, it is used as the response content schema directly. + - If the group has more than one contributing schema (necessarily of different `Kind`s per the rule in step 3), the response content schema is `anyOf` over the contributing schemas in the order the attributes were discovered. `anyOf` is used instead of `oneOf` for the same reason as in the error-narrowing design: the contributing envelopes can overlap structurally (both problem variants extend RFC 9457 Problem Details), so `oneOf`'s exclusivity rule would be violated. + + Each contributing schema is built by dispatching on the attribute's concrete type: + + - **`ProducesPortableSuccessResponseAttribute`** resolves the effective mode as `attr.MetadataSerializationMode ?? options.Value.MetadataSerializationMode` and obtains the value-type schema via `await context.GetOrCreateSchemaAsync(attr.ValueType, parameterDescription: null, cancellationToken)` (the second argument is always null for response types because `ApiParameterDescription` carries request-parameter context only). The returned `OpenApiSchema` may be inline (primitives, collections) or a reference to an existing component (complex types) — the transformer uses it as-is wherever a value schema is required. + - Under `ErrorsOnly` with no `TopLevelMetadataType`, the contributing schema is the returned `OpenApiSchema` directly. No envelope component is registered. + - Under `Always` (or whenever `TopLevelMetadataType` is set), the transformer synthesizes an operation-scoped envelope component whose `value` property's schema is the returned `OpenApiSchema` and whose `metadata` property is either the open-object canonical or — when `TopLevelMetadataType` is set — the schema produced by `GetOrCreateSchemaAsync(attr.TopLevelMetadataType)`. The envelope is registered via `document.AddComponent(name, envelopeSchema)` and referenced from the contributing schema. Per-operation synthesis (rather than reuse by `TValue`) is mandatory because ASP.NET Core leaves primitive and collection schemas inline — there is no stable `TValueSchemaId` to key reuse on for those payloads. + - If `TopLevelMetadataType` is set and the resolved mode is `ErrorsOnly`, the transformer throws `InvalidOperationException` at document generation because metadata is not part of the wire in that mode. + - **`ProducesPortableProblemAttribute`** and **`ProducesPortableValidationProblemAttribute`** (via the shared `PortableOpenApiErrorResponseAttributeBase`) reference the canonical schema directly by `$ref` when unconfigured, or produce a derived `allOf` envelope registered via `document.AddComponent` when any of `TopLevelMetadataType`, `ErrorCodes`, or `InlineErrorMetadataCodes` is set. Before synthesizing the operation-scoped inline variants, the transformer sanitizes each inline code and throws `InvalidOperationException` if two distinct inline codes on this `(operation, StatusCode, ContentType)` triple collide after sanitization; the exception names both raw codes so the caller can rename or globally register one of them. + - **Synthesized schema names** follow `______` (for example `PortableProblemDetails__GetMovies__404__application_problem_json`) and fall back to `________` when the operation has no `OperationId`. Segments are always separated by `__` (double underscore) so that component ids are visually and programmatically parseable into their constituent parts; characters within each segment are restricted to `[A-Za-z0-9_]` by sanitization so `__` is unambiguous as a segment delimiter. `SanitizedContentType` replaces `/`, `+`, `.`, `-`, and any other non-`[A-Za-z0-9_]` character with `_`. `SanitizedRoutePattern` applies the same rule to the raw route template and collapses adjacent replacement characters to a single `_`, so `/api/movies/{id}` becomes `api_movies_id`. Including the content-type token is required because one operation may legitimately document different narrowings for the same status code under different content types, and the transformer's duplicate-attribute check is keyed by `(StatusCode, ContentType)`. +5. Resolves the effective validation format for `Kind == ValidationProblem` attributes as `attr.Format ?? options.Value.ValidationProblemSerializationFormat`. The chosen format selects `PortableRichValidationProblemDetails` or `PortableAspNetCoreValidationProblemDetails` as the base schema in the `allOf`. +6. Emits discriminator narrowing using schema-level `const` when the resolved `OpenApiVersion` is OpenAPI 3.1 or later, and falls back to `enum: []` for OpenAPI 3.0. +7. Emits metadata DTO schemas (for `TopLevelMetadataType`, registry entries, and `InlineErrorMetadata` values) by calling `context.GetOrCreateSchemaAsync` on the CLR type and, when the transformer needs a stable reference, explicitly registering the returned schema via `document.AddComponent`. This keeps serializer configuration and polymorphism intact while ensuring every `$ref` the transformer emits points at a component it has actually registered. + +### Per-Error-Code Metadata Registry + +`IPortableErrorMetadataContractRegistry` (declared in `Light.PortableResults.AspNetCore.OpenApi`) is a singleton service with one property, `IReadOnlyDictionary Contracts`. Its default implementation, `PortableErrorMetadataContractRegistry`, is materialized from a `PortableErrorMetadataContractsBuilder` that callers populate through the DI extension method `ConfigureErrorMetadataContracts(this IServiceCollection services, Action configure)`. The builder exposes `ForCode(string code)` and `ForCode(string code, Type metadataType)` and is internally a `Dictionary`. + +Additive composition is delegated to the standard .NET options pipeline rather than hand-rolled. A small public options type `PortableErrorMetadataContractsOptions` owns a single `PortableErrorMetadataContractsBuilder Builder { get; } = new();` property. `ConfigureErrorMetadataContracts` is implemented as: + +```csharp +public static IServiceCollection ConfigureErrorMetadataContracts( + this IServiceCollection services, + Action configure) +{ + services.AddOptions(); + services.Configure(opts => configure(opts.Builder)); + services.TryAddSingleton(sp => + new PortableErrorMetadataContractRegistry( + sp.GetRequiredService>().Value.Builder)); + return services; +} +``` + +Each call to `ConfigureErrorMetadataContracts` registers another `IConfigureOptions` that runs in registration order against the same lazily-created options instance, so multiple calls from separate feature modules compose additively without any shared mutable service. `PortableErrorMetadataContractRegistry`'s constructor copies the builder's dictionary into an immutable snapshot, so the registry is frozen for the lifetime of the singleton. + +`PortableErrorMetadataContractsBuilder.ForCode` enforces the duplicate rule directly where the conflict is observable: registering a raw code whose existing entry already has the same `Type` is a no-op; registering it with a different `Type` throws `InvalidOperationException` naming the raw code and both types. The `PortableErrorMetadataContractRegistry` constructor repeats the same check while snapshotting the builder, so a conflict introduced by a late-running `IConfigureOptions` callback is still caught deterministically at materialization time with the same exception message. + +On first document generation the transformer synthesizes one `PortableError__` schema per registered code using the `allOf` pattern (the `code` constraint is encoded as `const` on OpenAPI 3.1+ and as `enum: []` on OpenAPI 3.0, and the component id applies the sanitization rule — any character outside `[A-Za-z0-9_]` replaced with `_`, collisions rejected at registration time): + +```text +allOf: + - $ref: PortableError + - properties: + code: { type: string, const: } + metadata: { $ref: } + required: [code] +``` + +And one `PortableValidationErrorDetail__` companion using the same pattern against `PortableValidationErrorDetail`. + +Endpoints that call `WithErrorCodes(...)` cause the transformer to synthesize a derived envelope whose `errors[*]` (rich + generic problem) or `errorDetails[*]` (asp.net-core-compatible validation) array item is an `anyOf` over the narrowed code schemas plus a trailing `$ref` to the baseline for undocumented codes, with a `discriminator` on `code` carrying an explicit `mapping` entry for every documented code: + +```text +anyOf: + - $ref: '#/components/schemas/PortableError__VersionMismatch' + - $ref: '#/components/schemas/PortableError__InsufficientFunds' + - $ref: '#/components/schemas/PortableError' # fallback for undocumented codes +discriminator: + propertyName: code + mapping: + VersionMismatch: '#/components/schemas/PortableError__VersionMismatch' + InsufficientFunds: '#/components/schemas/PortableError__InsufficientFunds' +``` + +`anyOf` is used instead of `oneOf` because every narrowed variant is an `allOf` restriction of the base `PortableError`, so any narrowed instance also validates against the base; that would violate `oneOf`'s exclusivity rule. Under `anyOf`, validators accept the instance against at least one branch, and discriminator-aware tooling uses the explicit `mapping` to pick the precise narrowed variant by code. Explicit `mapping` is required because implicit discriminator resolution matches on bare component names and our synthesized component ids are `PortableError__` rather than ``. + +The discriminator `mapping` keys are the raw wire codes (matching what the runtime writes to the `code` property), and the mapping values are JSON-Pointer-escaped `$ref`s per RFC 6901. Because the component id is always pre-sanitized to `[A-Za-z0-9_]` the `$ref` value rarely needs escaping in practice, but the transformer applies the escape unconditionally so that any change to the sanitization rule remains spec-valid. + +Inline `WithErrorMetadata(code, type)` follows the same mechanism but emits the synthesized narrowing schema scoped to the operation (for example `PortableError__GetMovies__409__application_problem_json__VersionMismatch`) so it does not pollute the global `PortableError__` namespace. The per-operation name includes the sanitized content type to prevent collisions when the same operation documents different narrowings per media type, and is also registered in the discriminator mapping for the operation's envelope. + +### Public API Shape + +The Minimal APIs helpers return a builder from the configuration callback. Three sealed builder classes cover the three response kinds. They do not share a public base: each one exposes exactly the members that are applicable to the corresponding concrete attribute, so there are no silent-ignore builder methods. + +- `PortableSuccessResponseOpenApiBuilder` — `WithMetadata()`, `WithMetadata(Type metadataType)`, `UseMetadataSerializationMode(MetadataSerializationMode mode)`. +- `PortableProblemOpenApiBuilder` — `WithMetadata()`, `WithMetadata(Type metadataType)`, `WithErrorCodes(params string[] codes)`, `WithErrorMetadata(string code, Type metadataType)`, `WithErrorMetadata(string code)`. +- `PortableValidationProblemOpenApiBuilder` — the same surface as `PortableProblemOpenApiBuilder` plus `UseFormat(ValidationProblemSerializationFormat format)`. + +All three builders are `sealed`. Each builder returns `this` from every method for chaining. Each method mutates settable properties on the paired concrete attribute instance the helper created up front (`PortableSuccessResponseOpenApiBuilder` mutates a `ProducesPortableSuccessResponseAttribute`, and so on); after the configure callback returns, the helper calls `RouteHandlerBuilder.WithMetadata(attributeInstance)` to attach the attribute as endpoint metadata. + +MVC attributes are the same sealed types described in the *Endpoint Metadata Attributes* section. Their settable properties use attribute-argument-compatible types per ECMA-335 §II.23.3 (primitives, `string`, `System.Type`, enums, or single-dimension arrays of those) — for example `string[]` instead of `IReadOnlyList`. Each attribute only exposes properties that are meaningful for its kind: the success attribute has no `ErrorCodes`, the problem and validation attributes have no `MetadataSerializationMode`, and only the validation attribute has `Format`. This is enforced by the type hierarchy rather than by runtime validation. + +Attribute instances reach `ActionDescriptor.EndpointMetadata` through the standard MVC endpoint-routing pipeline (attributes declared on a controller action are added automatically), so no `IEndpointMetadataProvider` implementation is needed. The transformer reads the same attribute instances that the Minimal APIs helpers attach via `RouteHandlerBuilder.WithMetadata(...)`, which gives both stacks a single metadata hierarchy rooted at `PortableOpenApiResponseAttributeBase`. + +The Minimal APIs helper and MVC attribute for success responses keep `TValue` as the only generic parameter and do not expose error-code members (success responses do not carry errors). + +### Scope Boundaries + +- This feature does not change runtime JSON serialization. +- This feature does not infer schemas from `PortableResultsHttpWriteOptions` or handler signatures beyond what the markers explicitly declare. +- This feature does not support Swashbuckle / NSwag. Consumers of those stacks continue to receive the runtime wire format but no Light.PortableResults-specific OpenAPI helpers. +- This feature does not attempt to represent `MetadataValueAnnotation` filtering in OpenAPI. The schema documents the broadest possible metadata shape; runtime filtering by annotation remains a runtime concern. +- This feature does not ship built-in error-code contracts for the validation package. The built-in `ValidationErrorDefinition` classes (in `Light.PortableResults.Validation`) already define a stable code-plus-metadata taxonomy via `ValidationErrorMetadataKeys`, and a follow-up plan (`0040-2-validation-error-contracts.md`) will wire them into `IPortableErrorMetadataContractRegistry` through an opt-in `RegisterBuiltInValidationErrors()` extension. This redesign keeps the registry surface minimal (type-based contracts only) and is forward-compatible: the follow-up widens the contract value from `Type` to a discriminated union that also accepts pre-authored `OpenApiSchema` instances without breaking existing registrations. From 454c2e48c3afc83ccf2807fb01422ceb24a995bc Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Mon, 20 Apr 2026 13:46:16 +0200 Subject: [PATCH 10/67] feat: initial redesign of OpenAPI support Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 2 + README.md | 112 ++- ai-plans/0040-1-openapi-redesign.md | 46 +- ai-plans/0040-2-validation-error-contracts.md | 86 ++ .../AddMovieRating/AddMovieRatingEndpoint.cs | 4 +- .../GetMovies/GetMoviesEndpoint.cs | 4 +- .../MovieRatingJsonContext.cs | 5 - .../NativeAotMovieRating.csproj | 6 +- samples/NativeAotMovieRating/Program.cs | 9 +- .../NativeAotMovieRating/packages.lock.json | 7 + ...tableResults.AspNetCore.MinimalApis.csproj | 1 + .../PortableResultsEndpointExtensions.cs | 143 ---- .../packages.lock.json | 6 + ...ight.PortableResults.AspNetCore.Mvc.csproj | 1 + ...bleAspNetCoreValidationProblemAttribute.cs | 54 -- .../ProducesPortableProblemAttribute.cs | 44 -- ...sPortableRichValidationProblemAttribute.cs | 48 -- ...roducesPortableSuccessResponseAttribute.cs | 26 - .../IPortableErrorMetadataContractRegistry.cs | 15 + .../InternalOpenApiAttributeInterfaces.cs | 11 + ....PortableResults.AspNetCore.OpenApi.csproj | 23 + .../PortableErrorMetadataContractRegistry.cs | 61 ++ .../PortableErrorMetadataContractsBuilder.cs | 64 ++ .../PortableErrorMetadataContractsOptions.cs | 12 + .../PortableOpenApiBuilderUtilities.cs | 58 ++ .../PortableOpenApiResponseAttributeBase.cs | 81 ++ .../PortableOpenApiResponseKind.cs | 22 + .../PortableProblemOpenApiBuilder.cs | 68 ++ ...rtableResultsOpenApiDocumentTransformer.cs | 738 ++++++++++++++++++ .../PortableResultsOpenApiMessages.cs | 23 + .../PortableResultsOpenApiModule.cs | 65 ++ ...ltsOpenApiRouteHandlerBuilderExtensions.cs | 67 ++ .../PortableResultsOpenApiSchemaNaming.cs | 114 +++ .../PortableResultsOpenApiSchemas.cs | 202 +++++ .../PortableSuccessResponseOpenApiBuilder.cs | 50 ++ ...PortableValidationProblemOpenApiBuilder.cs | 79 ++ .../ProducesPortableProblemAttribute.cs | 21 + ...roducesPortableSuccessResponseAttribute.cs | 52 ++ ...ducesPortableValidationProblemAttribute.cs | 39 + .../packages.lock.json | 87 +++ ...tableAspNetCoreValidationProblemDetails.cs | 53 -- .../PortableError.cs | 82 -- .../PortableProblemDetails.cs | 42 - ...PortableResultsOpenApiNamingConventions.cs | 78 -- .../PortableRichValidationProblemDetails.cs | 45 -- .../PortableSuccessResponse.cs | 30 - .../PortableValidationErrorDetail.cs | 88 --- .../packages.lock.json | 6 + .../Light.PortableResults.Validation.csproj | 3 +- .../Light.PortableResults.csproj | 3 +- .../PortableResultsEndpointExtensionsTests.cs | 123 --- .../IntegrationTests/RegularMvcController.cs | 2 +- .../ProducesPortableAttributesTests.cs | 99 --- ...bleResults.AspNetCore.OpenApi.Tests.csproj | 32 + ...eResultsOpenApiDocumentTransformerTests.cs | 425 ++++++++++ .../PortableResultsOpenApiSchemasTests.cs | 31 + .../packages.lock.json | 305 ++++++++ .../xunit.runner.json | 3 + ...bleResultsOpenApiNamingConventionsTests.cs | 80 -- .../PortableSchemaTypesTests.cs | 259 ------ 60 files changed, 2973 insertions(+), 1372 deletions(-) create mode 100644 ai-plans/0040-2-validation-error-contracts.md delete mode 100644 src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseKind.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableError.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs delete mode 100644 src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs delete mode 100644 tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs delete mode 100644 tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/xunit.runner.json delete mode 100644 tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs delete mode 100644 tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 4cd67a0..785dec0 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -78,12 +78,14 @@ + + diff --git a/README.md b/README.md index 3b25a03..2256cd6 100644 --- a/README.md +++ b/README.md @@ -1324,73 +1324,100 @@ services ## OpenAPI Support -Light.PortableResults ships schema-only CLR types and endpoint metadata helpers so OpenAPI generators can emit accurate response schemas for both success and failure responses. These helpers are documentation-only: the runtime HTTP serialization still happens through `LightResult` / `LightActionResult` and the JSON writers in `Light.PortableResults`. +OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult` / `LightActionResult` still serialize through the JSON writers in `Light.PortableResults`; the OpenAPI package only contributes endpoint metadata plus a document transformer. -### Success responses: when to use which helper +### Registration -A successful `Result` serializes to one of two body shapes: +```csharp +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; + +builder.Services + .AddPortableResultsForMinimalApis() + .AddPortableResultsOpenApi() + .AddOpenApi(); +``` + +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 -- If the body is just `TValue`, document it with the standard ASP.NET Core OpenAPI helpers such as `Produces(...)` on Minimal APIs or `ProducesResponseType` on MVC controllers. Do **not** use Light.PortableResults-specific success helpers for this case. -- If the body is `{ value, metadata }` (see `MetadataSerializationMode.Always`), document it with `ProducesPortableSuccessResponse(...)` or `[ProducesPortableSuccessResponse]`. The metadata type is now an explicit part of the contract. +Minimal APIs expose three helpers in `Light.PortableResults.AspNetCore.OpenApi`: -### Failure responses +- `ProducesPortableSuccessResponse(...)` +- `ProducesPortableProblem(...)` +- `ProducesPortableValidationProblem(...)` -Failure responses are documented with dedicated helpers per problem-details shape: +MVC exposes three matching attributes: -- `ProducesPortableProblem` / `ProducesPortableProblemAttribute` for non-validation failures (401, 403, 404, 409, 500, ...). Pass the relevant status code; there are no dedicated per-status-code helpers. -- `ProducesPortableRichValidationProblem` / `ProducesPortableRichValidationProblemAttribute` when `ValidationProblemSerializationFormat` is set to `Rich`. Here the `errors` property is documented as an array of Light.PortableResults-style error objects. -- `ProducesPortableAspNetCoreValidationProblem` / `ProducesPortableAspNetCoreValidationProblemAttribute` when `ValidationProblemSerializationFormat` is set to `AspNetCoreCompatible` (the default). Here the inherited `errors` property is documented as `Dictionary`, and an optional `errorDetails` array carries Light.PortableResults-specific information such as codes, categories, and metadata. +- `[ProducesPortableSuccessResponse]` +- `[ProducesPortableProblem]` +- `[ProducesPortableValidationProblem]` -You must choose the OpenAPI helper that matches the actual configured `ValidationProblemSerializationFormat`; the library does not infer the validation shape for you. +`ProducesPortableSuccessResponse` documents both runtime success shapes: -The `Index` property on `PortableValidationErrorDetail` is the zero-based position of the corresponding error message within the `errors[target]` array for the same target, so `errorDetails` entries can be correlated back to the matching message. +- Under `MetadataSerializationMode.ErrorsOnly`, the documented body is the bare `TValue`. +- Under `MetadataSerializationMode.Always`, the documented body is `{ value, metadata }`. -Each helper family has a non-generic overload and a two-generic overload. The two-generic overloads let you strongly type per-error metadata and top-level problem metadata. There are no one-generic middle-tier overloads; callers who want only typed problem metadata use the two-generic overload with `object` as the first type argument. +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. -The typed metadata CLR types are schema-only helpers for OpenAPI. The runtime always serializes `MetadataObject`, so you are responsible for keeping the documented schema aligned with the metadata you actually produce. +`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. -### Minimal APIs example (rich validation) +### 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`. + +Register reusable per-error-code metadata contracts once in DI: ```csharp -using Light.PortableResults; -using Light.PortableResults.AspNetCore.MinimalApis; -using Light.PortableResults.Metadata; +using Light.PortableResults.AspNetCore.OpenApi; -app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => - { - var result = await service.AddMovieRatingAsync(dto); - return result.ToMinimalApiResult(); - }) - .ProducesPortableSuccessResponse() - .ProducesPortableRichValidationProblem() - .ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound) - .ProducesPortableProblem(); +builder.Services.ConfigureErrorMetadataContracts(contracts => +{ + contracts.ForCode("VersionMismatch"); + contracts.ForCode("InsufficientFunds"); +}); ``` -### Minimal APIs example (ASP.NET Core-compatible validation) +Then opt the relevant codes into each endpoint: ```csharp using Light.PortableResults; using Light.PortableResults.AspNetCore.MinimalApis; -using Light.PortableResults.Metadata; +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() - .ProducesPortableAspNetCoreValidationProblem() - .ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound) + .ProducesPortableSuccessResponse( + configure: x => + x.WithMetadata() + .UseMetadataSerializationMode(MetadataSerializationMode.Always) + ) + .ProducesPortableValidationProblem( + configure: x => + x.UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes("VersionMismatch") + ) + .ProducesPortableProblem( + statusCode: StatusCodes.Status404NotFound, + configure: x => + x.WithMetadata() + .WithErrorMetadata("MovieNotFound") + ) .ProducesPortableProblem(); ``` -### MVC example +The MVC equivalent uses named attribute arguments: ```csharp using Light.PortableResults; using Light.PortableResults.AspNetCore.Mvc; -using Light.PortableResults.Metadata; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; using Microsoft.AspNetCore.Mvc; [ApiController] @@ -1398,9 +1425,20 @@ using Microsoft.AspNetCore.Mvc; public sealed class AddMovieRatingsController(AddMovieRatingService service) : ControllerBase { [HttpPut] - [ProducesPortableSuccessResponse] - [ProducesPortableRichValidationProblem] - [ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound)] + [ProducesPortableSuccessResponse( + TopLevelMetadataType = typeof(MovieRatingResponseMetadata), + MetadataSerializationMode = MetadataSerializationMode.Always + )] + [ProducesPortableValidationProblem( + Format = ValidationProblemSerializationFormat.Rich, + ErrorCodes = new[] { "VersionMismatch" } + )] + [ProducesPortableProblem( + statusCode: StatusCodes.Status404NotFound, + TopLevelMetadataType = typeof(MovieProblemMetadata), + InlineErrorMetadataCodes = new[] { "MovieNotFound" }, + InlineErrorMetadataTypes = new[] { typeof(MovieNotFoundMetadata) } + )] [ProducesPortableProblem] public async Task> AddMovieRating(AddMovieRatingDto dto) { diff --git a/ai-plans/0040-1-openapi-redesign.md b/ai-plans/0040-1-openapi-redesign.md index f09c204..8ca5ca5 100644 --- a/ai-plans/0040-1-openapi-redesign.md +++ b/ai-plans/0040-1-openapi-redesign.md @@ -14,32 +14,32 @@ This plan supersedes the OpenAPI portions of `0040-0-openapi-support.md`. The br ## Acceptance Criteria -- [ ] All schema-only CLR types introduced by `0040-0-openapi-support.md` are deleted: `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableSuccessResponse`. -- [ ] `PortableResultsOpenApiNamingConventions` is deleted together with its tests. -- [ ] All two-generic endpoint helpers on `PortableResultsEndpointExtensions` and all two-generic MVC attributes are deleted. The helper/attribute split between `Rich` and `AspNetCoreCompatible` validation problems is collapsed into a single helper/attribute. -- [ ] The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` no longer expose any OpenAPI helper or attribute surface at all. Concretely, the entire `PortableResultsEndpointExtensions` class is deleted from `Light.PortableResults.AspNetCore.MinimalApis` (including every non-generic helper such as `ProducesPortableProblem`, `ProducesPortableRichValidationProblem`, and `ProducesPortableAspNetCoreValidationProblem`, not only the two-generic overloads), and `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, `ProducesPortableRichValidationProblemAttribute`, and `ProducesPortableAspNetCoreValidationProblemAttribute` are deleted from `Light.PortableResults.AspNetCore.Mvc`. The replacements live exclusively in the new `Light.PortableResults.AspNetCore.OpenApi` package so there is a single public OpenAPI surface across the solution. -- [ ] A new project `Light.PortableResults.AspNetCore.OpenApi` is added to the solution. It targets .NET 10, sets `true`, project-references `Light.PortableResults.AspNetCore.Shared`, carries a ``, and takes on the NuGet `` at the version already pinned in `Directory.Packages.props`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` do not gain this package reference. -- [ ] `Light.PortableResults.AspNetCore.OpenApi` contains a library-authored OpenAPI schema catalog class named `PortableResultsOpenApiSchemas` that writes exactly five canonical envelope components into `OpenApiDocument.Components.Schemas` under the exact ids `PortableError`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`, plus a supporting `ErrorCategory` enum component (six schema components total). The `metadata`, `errorDetails[*].metadata`, and `errors[*].metadata` slots are declared as open objects (`type: object, additionalProperties: true`) to match what `MetadataExtensions.WriteMetadataObject` actually emits. Success envelopes are not part of the canonical catalog; they are synthesized per operation by the transformer because they only take a stable shape in the context of a specific `TValue`. -- [ ] `Light.PortableResults.AspNetCore.OpenApi` contains an `IOpenApiDocumentTransformer` implementation named `PortableResultsOpenApiDocumentTransformer` that (a) installs the canonical catalog once per document, (b) resolves the effective validation format from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` or the per-endpoint override, and (c) synthesizes any operation-specific derived schemas required by the markers attached to each endpoint. -- [ ] `Light.PortableResults.AspNetCore.OpenApi` exposes a single opt-in entry point `AddPortableResultsOpenApi(this IServiceCollection services)` that registers `PortableResultsOpenApiDocumentTransformer` and its `ConfigureAll` hook idempotently (using `TryAddSingleton` for the transformer plus a private gate service so repeated calls register the configure-options callback exactly once). Consumers who want OpenAPI support call this alongside `AddPortableResultsForMinimalApis` and/or `AddPortableResultsForMvc`. `AddPortableResultsForMinimalApis` and `AddPortableResultsForMvc` do **not** transitively call `AddPortableResultsOpenApi`, so applications that never touch OpenAPI are unaffected. Callers do not need to configure `OpenApiOptions.CreateSchemaReferenceId`. -- [ ] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly the following `RouteHandlerBuilder` extension methods on `PortableResultsOpenApiRouteHandlerBuilderExtensions`, where `TValue` is the only generic on the public helper surface: +- [x] All schema-only CLR types introduced by `0040-0-openapi-support.md` are deleted: `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableSuccessResponse`. +- [x] `PortableResultsOpenApiNamingConventions` is deleted together with its tests. +- [x] All two-generic endpoint helpers on `PortableResultsEndpointExtensions` and all two-generic MVC attributes are deleted. The helper/attribute split between `Rich` and `AspNetCoreCompatible` validation problems is collapsed into a single helper/attribute. +- [x] The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` no longer expose any OpenAPI helper or attribute surface at all. Concretely, the entire `PortableResultsEndpointExtensions` class is deleted from `Light.PortableResults.AspNetCore.MinimalApis` (including every non-generic helper such as `ProducesPortableProblem`, `ProducesPortableRichValidationProblem`, and `ProducesPortableAspNetCoreValidationProblem`, not only the two-generic overloads), and `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, `ProducesPortableRichValidationProblemAttribute`, and `ProducesPortableAspNetCoreValidationProblemAttribute` are deleted from `Light.PortableResults.AspNetCore.Mvc`. The replacements live exclusively in the new `Light.PortableResults.AspNetCore.OpenApi` package so there is a single public OpenAPI surface across the solution. +- [x] A new project `Light.PortableResults.AspNetCore.OpenApi` is added to the solution. It targets .NET 10, sets `true`, project-references `Light.PortableResults.AspNetCore.Shared`, carries a ``, and takes on the NuGet `` at the version already pinned in `Directory.Packages.props`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` do not gain this package reference. +- [x] `Light.PortableResults.AspNetCore.OpenApi` contains a library-authored OpenAPI schema catalog class named `PortableResultsOpenApiSchemas` that writes exactly five canonical envelope components into `OpenApiDocument.Components.Schemas` under the exact ids `PortableError`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`, plus a supporting `ErrorCategory` enum component (six schema components total). The `metadata`, `errorDetails[*].metadata`, and `errors[*].metadata` slots are declared as open objects (`type: object, additionalProperties: true`) to match what `MetadataExtensions.WriteMetadataObject` actually emits. Success envelopes are not part of the canonical catalog; they are synthesized per operation by the transformer because they only take a stable shape in the context of a specific `TValue`. +- [x] `Light.PortableResults.AspNetCore.OpenApi` contains an `IOpenApiDocumentTransformer` implementation named `PortableResultsOpenApiDocumentTransformer` that (a) installs the canonical catalog once per document, (b) resolves the effective validation format from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` or the per-endpoint override, and (c) synthesizes any operation-specific derived schemas required by the markers attached to each endpoint. +- [x] `Light.PortableResults.AspNetCore.OpenApi` exposes a single opt-in entry point `AddPortableResultsOpenApi(this IServiceCollection services)` that registers `PortableResultsOpenApiDocumentTransformer` and its `ConfigureAll` hook idempotently (using `TryAddSingleton` for the transformer plus a private gate service so repeated calls register the configure-options callback exactly once). Consumers who want OpenAPI support call this alongside `AddPortableResultsForMinimalApis` and/or `AddPortableResultsForMvc`. `AddPortableResultsForMinimalApis` and `AddPortableResultsForMvc` do **not** transitively call `AddPortableResultsOpenApi`, so applications that never touch OpenAPI are unaffected. Callers do not need to configure `OpenApiOptions.CreateSchemaReferenceId`. +- [x] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly the following `RouteHandlerBuilder` extension methods on `PortableResultsOpenApiRouteHandlerBuilderExtensions`, where `TValue` is the only generic on the public helper surface: - `ProducesPortableSuccessResponse(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, string contentType = "application/json", Action? configure = null)` - `ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json", Action? configure = null)` - `ProducesPortableValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json", Action? configure = null)` -- [ ] `PortableSuccessResponseOpenApiBuilder` exposes `UseMetadataSerializationMode(MetadataSerializationMode mode)` as a per-endpoint static override, mirroring the existing `UseFormat(ValidationProblemSerializationFormat)` override on `PortableValidationProblemOpenApiBuilder`. The documented schema is selected by the transformer from the resolved mode, so callers can either take the DI default or override it per endpoint for documentation purposes. It remains the caller's responsibility to align any runtime `overrideOptions` passed to `LightResult` / `LightActionResult` with the documented mode. -- [ ] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly three attributes: `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, and `ProducesPortableValidationProblemAttribute`. Each is `sealed` and works for both MVC controllers (applied to an action method) and Minimal APIs (the corresponding helper constructs and attaches an instance via `RouteHandlerBuilder.WithMetadata`). They sit in a three-level hierarchy designed so that every public knob is applicable to the type it is declared on (no silent ignores): a public abstract base `PortableOpenApiResponseAttributeBase : Attribute` carries only the truly shared knobs (`Kind`, `StatusCode`, `ContentType`, `TopLevelMetadataType`); a public abstract intermediate `PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase` adds the error-list knobs (`ErrorCodes`, `InlineErrorMetadataCodes`, `InlineErrorMetadataTypes`); the three sealed attributes add kind-specific knobs directly — the success attribute adds `ValueType` (set in its constructor from `typeof(TValue)`) and `MetadataSerializationMode`, the problem attribute adds nothing beyond the error base, the validation attribute adds `Format`. Each sealed attribute exposes a constructor accepting `(int statusCode, string contentType)` with defaults matching its Minimal APIs counterpart (`200` / `application/json` for the success attribute, `500` / `application/problem+json` for the problem attribute, `400` / `application/problem+json` for the validation attribute), so call sites read naturally as `[ProducesPortableProblem(404)]` rather than `[ProducesPortableProblem(StatusCode = 404)]`. The base is intentionally not derived from `ProducesResponseTypeAttribute` because the transformer owns schema selection end-to-end; a consequence is that MVC filters and analyzers that enumerate `ProducesResponseTypeAttribute` (for example the default `ApiExplorer` content-negotiation behavior) will not see Light.PortableResults responses, and the document transformer is the single source of truth for these operations. MVC attribute instances enter endpoint metadata through the standard MVC endpoint-routing pipeline (attributes on a controller action are added to `ActionDescriptor.EndpointMetadata` automatically), so the attributes do not need to implement `IEndpointMetadataProvider`. -- [ ] A global error-code metadata registry is exposed through the extension method `ConfigureErrorMetadataContracts(this IServiceCollection services, Action configure)` declared in `Light.PortableResults.AspNetCore.OpenApi`. `PortableErrorMetadataContractsBuilder` exposes `ForCode(string code)` and `ForCode(string code, Type metadataType)` registration methods. The registrations are stored in a singleton service `IPortableErrorMetadataContractRegistry` with an immutable `IReadOnlyDictionary Contracts` property. Registered codes are synthesized into `PortableError__` and `PortableValidationErrorDetail__` schema components once per document (see the sanitization criterion below); endpoints opt into specific codes via `WithErrorCodes(params string[])`. When `WithErrorCodes` references a code that is not present in `IPortableErrorMetadataContractRegistry.Contracts`, the transformer throws `InvalidOperationException` at document generation with a message that names the unregistered code and suggests either registering it through `ConfigureErrorMetadataContracts` or using the inline `WithErrorMetadata` escape hatch. Inline escape hatches `WithErrorMetadata(string code, Type metadataType)` and `WithErrorMetadata(string code)` are available on the problem and validation-problem endpoint builders for codes that are not globally registered. -- [ ] `ConfigureErrorMetadataContracts` is implemented on top of the standard .NET options pipeline: it wraps the caller's `Action` in a `services.Configure(...)` registration (where `PortableErrorMetadataContractsOptions` is a small public options type owning a single `Builder` property), and registers `IPortableErrorMetadataContractRegistry` via `TryAddSingleton` with a factory that materializes the immutable registry from `IOptions.Value.Builder`. This gives additive composition for free: multiple invocations (for example from separate feature modules during composition-root setup) each register another `IConfigureOptions` that runs in registration order against the same lazily-created options instance. Registering the same raw code twice with the same `Type` is an idempotent no-op. Registering the same raw code twice with two different `Type`s throws `InvalidOperationException` with a message naming the raw code and both conflicting types; the throw fires either inside `PortableErrorMetadataContractsBuilder.ForCode` (when the conflict is observable to the builder at configure time) or at registry materialization (when two independent configure callbacks contribute conflicting entries). -- [ ] Per-endpoint metadata narrowing is expressed in the emitted OpenAPI document using `allOf` to extend a canonical envelope and `anyOf + discriminator` on the error `code` property to narrow `errors[*]` (rich format) or `errorDetails[*]` (asp.net-core-compatible format). The transformer emits an explicit `discriminator.mapping` whose keys are the raw code strings as they appear on the wire and whose values are JSON-Pointer-escaped `$ref`s to the synthesized variants (for example `VersionMismatch: '#/components/schemas/PortableError__VersionMismatch'`), because implicit discriminator resolution matches on bare component name and our synthesized component ids are `PortableError__`. A fallback `$ref` to the base `PortableError` / `PortableValidationErrorDetail` schema is always included as the last branch of the `anyOf` so that undocumented codes remain valid; `anyOf` is used instead of `oneOf` because every narrowed variant is an `allOf` restriction of the base schema and would therefore also match the base, which violates `oneOf` semantics. -- [ ] The transformer applies a deterministic sanitization scheme to every error code used in a component id: characters outside `[A-Za-z0-9_]` are replaced with `_`. Collisions after sanitization are rejected at the earliest possible moment: `ConfigureErrorMetadataContracts` throws `InvalidOperationException` at registration time when two distinct globally registered codes sanitize to the same id, and the transformer throws `InvalidOperationException` at document generation time when two distinct inline `WithErrorMetadata` codes on the same `(operation, StatusCode, ContentType)` triple sanitize to the same suffix; both messages name the conflicting raw codes. The discriminator `mapping` keys use the unsanitized raw code (matching what the runtime writes to the `code` property), and the discriminator `mapping` values and operation-level `$ref`s apply JSON Pointer escaping per RFC 6901 (`~` → `~0`, `/` → `~1`) defensively. Sanitization applies identically to `PortableError__`, `PortableValidationErrorDetail__`, and operation-scoped inline variants. -- [ ] The runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, and the JSON writers in `Light.PortableResults` is unchanged. -- [ ] The transformer resolves the target OpenAPI spec version from a single source of truth: `OpenApiOptions.OpenApiVersion` for the current document, obtained via `context.ApplicationServices.GetRequiredService>().Get(context.DocumentName)`. It emits discriminator narrowing using schema-level `const` when the resolved version is OpenAPI 3.1 or later, and falls back to `enum: []` for OpenAPI 3.0. Generated schemas are spec-valid against both versions. -- [ ] When multiple `PortableOpenApiResponseAttributeBase` instances share the same `(StatusCode, ContentType)` key on the same operation, the transformer treats them as distinct contributing schemas for the same HTTP response and merges them into a single `OpenApiResponse` whose media-type schema is an `anyOf` over the contributing envelopes, so common designs such as documenting both a `ProducesPortableProblemAttribute(400)` and a `ProducesPortableValidationProblemAttribute(400)` on the same endpoint at `application/problem+json` produce one response entry with a unioned schema. The transformer still throws `InvalidOperationException` at document generation time when more than one marker of the same `Kind` is attached to the same operation for the same `(StatusCode, ContentType)` key (for example two `ProducesPortableProblemAttribute`s with identical status and content type), because that is a genuine ambiguity about which narrowing to emit. It also throws `InvalidOperationException` when an attribute instance has both `InlineErrorMetadataCodes` and `InlineErrorMetadataTypes` set to non-null arrays of different lengths; the exception message includes both observed lengths so the caller can realign them. -- [ ] The `PackageReleaseNotes` section of `Light.PortableResults.AspNetCore.MinimalApis.csproj` and `Light.PortableResults.AspNetCore.Mvc.csproj` is updated to call out the removal of the schema-only CLR types and the helper/attribute collapse, and to point consumers at the new `Light.PortableResults.AspNetCore.OpenApi` package for OpenAPI integration. `Light.PortableResults.AspNetCore.OpenApi.csproj` carries its own `PackageReleaseNotes` introducing the package, its opt-in `AddPortableResultsOpenApi` entry point, the canonical schema catalog, the three helpers, the three attributes, and the error-metadata registry. -- [ ] Automated tests cover the document transformer end-to-end: canonical catalog emission, each helper/attribute's effect on the generated document, global error-code registry integration, inline escape hatch, per-endpoint format override, success-response metadata narrowing, and the fallback `$ref` for undocumented codes. -- [ ] The `NativeAotMovieRating` sample is updated to the new public API (adds a `ProjectReference` to `Light.PortableResults.AspNetCore.OpenApi`, calls `AddPortableResultsOpenApi()`, uses the new helpers) and no longer wires `OpenApiOptions.CreateSchemaReferenceId`. -- [ ] The new OpenAPI surface remains NativeAOT-compatible. The `NativeAotMovieRating` sample continues to build and run under `PublishAot=true`, and the document transformer uses only APIs compatible with the trimmer and AOT analyzer (no `Type.MakeGenericType`, no dynamic assembly emit, no reflection over handler parameters; all generic dispatch happens through the existing `GetOrCreateSchemaAsync` API and through attribute instances supplied at compile time). -- [ ] `README.md` is updated: the OpenAPI section reflects the new public surface (new `Light.PortableResults.AspNetCore.OpenApi` package, opt-in `AddPortableResultsOpenApi()`, three helpers, three attributes, DI-level `ConfigureErrorMetadataContracts`, per-endpoint format override), and all references to the deleted schema-only CLR types, the naming convention, and the `Rich` vs `AspNetCoreCompatible` helper split are removed. +- [x] `PortableSuccessResponseOpenApiBuilder` exposes `UseMetadataSerializationMode(MetadataSerializationMode mode)` as a per-endpoint static override, mirroring the existing `UseFormat(ValidationProblemSerializationFormat)` override on `PortableValidationProblemOpenApiBuilder`. The documented schema is selected by the transformer from the resolved mode, so callers can either take the DI default or override it per endpoint for documentation purposes. It remains the caller's responsibility to align any runtime `overrideOptions` passed to `LightResult` / `LightActionResult` with the documented mode. +- [x] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly three attributes: `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, and `ProducesPortableValidationProblemAttribute`. Each is `sealed` and works for both MVC controllers (applied to an action method) and Minimal APIs (the corresponding helper constructs and attaches an instance via `RouteHandlerBuilder.WithMetadata`). They sit in a three-level hierarchy designed so that every public knob is applicable to the type it is declared on (no silent ignores): a public abstract base `PortableOpenApiResponseAttributeBase : Attribute` carries only the truly shared knobs (`Kind`, `StatusCode`, `ContentType`, `TopLevelMetadataType`); a public abstract intermediate `PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase` adds the error-list knobs (`ErrorCodes`, `InlineErrorMetadataCodes`, `InlineErrorMetadataTypes`); the three sealed attributes add kind-specific knobs directly — the success attribute adds `ValueType` (set in its constructor from `typeof(TValue)`) and `MetadataSerializationMode`, the problem attribute adds nothing beyond the error base, the validation attribute adds `Format`. Each sealed attribute exposes a constructor accepting `(int statusCode, string contentType)` with defaults matching its Minimal APIs counterpart (`200` / `application/json` for the success attribute, `500` / `application/problem+json` for the problem attribute, `400` / `application/problem+json` for the validation attribute), so call sites read naturally as `[ProducesPortableProblem(404)]` rather than `[ProducesPortableProblem(StatusCode = 404)]`. The base is intentionally not derived from `ProducesResponseTypeAttribute` because the transformer owns schema selection end-to-end; a consequence is that MVC filters and analyzers that enumerate `ProducesResponseTypeAttribute` (for example the default `ApiExplorer` content-negotiation behavior) will not see Light.PortableResults responses, and the document transformer is the single source of truth for these operations. MVC attribute instances enter endpoint metadata through the standard MVC endpoint-routing pipeline (attributes on a controller action are added to `ActionDescriptor.EndpointMetadata` automatically), so the attributes do not need to implement `IEndpointMetadataProvider`. +- [x] A global error-code metadata registry is exposed through the extension method `ConfigureErrorMetadataContracts(this IServiceCollection services, Action configure)` declared in `Light.PortableResults.AspNetCore.OpenApi`. `PortableErrorMetadataContractsBuilder` exposes `ForCode(string code)` and `ForCode(string code, Type metadataType)` registration methods. The registrations are stored in a singleton service `IPortableErrorMetadataContractRegistry` with an immutable `IReadOnlyDictionary Contracts` property. Registered codes are synthesized into `PortableError__` and `PortableValidationErrorDetail__` schema components once per document (see the sanitization criterion below); endpoints opt into specific codes via `WithErrorCodes(params string[])`. When `WithErrorCodes` references a code that is not present in `IPortableErrorMetadataContractRegistry.Contracts`, the transformer throws `InvalidOperationException` at document generation with a message that names the unregistered code and suggests either registering it through `ConfigureErrorMetadataContracts` or using the inline `WithErrorMetadata` escape hatch. Inline escape hatches `WithErrorMetadata(string code, Type metadataType)` and `WithErrorMetadata(string code)` are available on the problem and validation-problem endpoint builders for codes that are not globally registered. +- [x] `ConfigureErrorMetadataContracts` is implemented on top of the standard .NET options pipeline: it wraps the caller's `Action` in a `services.Configure(...)` registration (where `PortableErrorMetadataContractsOptions` is a small public options type owning a single `Builder` property), and registers `IPortableErrorMetadataContractRegistry` via `TryAddSingleton` with a factory that materializes the immutable registry from `IOptions.Value.Builder`. This gives additive composition for free: multiple invocations (for example from separate feature modules during composition-root setup) each register another `IConfigureOptions` that runs in registration order against the same lazily-created options instance. Registering the same raw code twice with the same `Type` is an idempotent no-op. Registering the same raw code twice with two different `Type`s throws `InvalidOperationException` with a message naming the raw code and both conflicting types; the throw fires either inside `PortableErrorMetadataContractsBuilder.ForCode` (when the conflict is observable to the builder at configure time) or at registry materialization (when two independent configure callbacks contribute conflicting entries). +- [x] Per-endpoint metadata narrowing is expressed in the emitted OpenAPI document using `allOf` to extend a canonical envelope and `anyOf + discriminator` on the error `code` property to narrow `errors[*]` (rich format) or `errorDetails[*]` (asp.net-core-compatible format). The transformer emits an explicit `discriminator.mapping` whose keys are the raw code strings as they appear on the wire and whose values are JSON-Pointer-escaped `$ref`s to the synthesized variants (for example `VersionMismatch: '#/components/schemas/PortableError__VersionMismatch'`), because implicit discriminator resolution matches on bare component name and our synthesized component ids are `PortableError__`. A fallback `$ref` to the base `PortableError` / `PortableValidationErrorDetail` schema is always included as the last branch of the `anyOf` so that undocumented codes remain valid; `anyOf` is used instead of `oneOf` because every narrowed variant is an `allOf` restriction of the base schema and would therefore also match the base, which violates `oneOf` semantics. +- [x] The transformer applies a deterministic sanitization scheme to every error code used in a component id: characters outside `[A-Za-z0-9_]` are replaced with `_`. Collisions after sanitization are rejected at the earliest possible moment: `ConfigureErrorMetadataContracts` throws `InvalidOperationException` at registration time when two distinct globally registered codes sanitize to the same id, and the transformer throws `InvalidOperationException` at document generation time when two distinct inline `WithErrorMetadata` codes on the same `(operation, StatusCode, ContentType)` triple sanitize to the same suffix; both messages name the conflicting raw codes. The discriminator `mapping` keys use the unsanitized raw code (matching what the runtime writes to the `code` property), and the discriminator `mapping` values and operation-level `$ref`s apply JSON Pointer escaping per RFC 6901 (`~` → `~0`, `/` → `~1`) defensively. Sanitization applies identically to `PortableError__`, `PortableValidationErrorDetail__`, and operation-scoped inline variants. +- [x] The runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, and the JSON writers in `Light.PortableResults` is unchanged. +- [x] The transformer resolves the target OpenAPI spec version from a single source of truth: `OpenApiOptions.OpenApiVersion` for the current document, obtained via `context.ApplicationServices.GetRequiredService>().Get(context.DocumentName)`. It emits discriminator narrowing using schema-level `const` when the resolved version is OpenAPI 3.1 or later, and falls back to `enum: []` for OpenAPI 3.0. Generated schemas are spec-valid against both versions. +- [x] When multiple `PortableOpenApiResponseAttributeBase` instances share the same `(StatusCode, ContentType)` key on the same operation, the transformer treats them as distinct contributing schemas for the same HTTP response and merges them into a single `OpenApiResponse` whose media-type schema is an `anyOf` over the contributing envelopes, so common designs such as documenting both a `ProducesPortableProblemAttribute(400)` and a `ProducesPortableValidationProblemAttribute(400)` on the same endpoint at `application/problem+json` produce one response entry with a unioned schema. The transformer still throws `InvalidOperationException` at document generation time when more than one marker of the same `Kind` is attached to the same operation for the same `(StatusCode, ContentType)` key (for example two `ProducesPortableProblemAttribute`s with identical status and content type), because that is a genuine ambiguity about which narrowing to emit. It also throws `InvalidOperationException` when an attribute instance has both `InlineErrorMetadataCodes` and `InlineErrorMetadataTypes` set to non-null arrays of different lengths; the exception message includes both observed lengths so the caller can realign them. +- [x] The `PackageReleaseNotes` section of `Light.PortableResults.AspNetCore.MinimalApis.csproj` and `Light.PortableResults.AspNetCore.Mvc.csproj` is updated to call out the removal of the schema-only CLR types and the helper/attribute collapse, and to point consumers at the new `Light.PortableResults.AspNetCore.OpenApi` package for OpenAPI integration. `Light.PortableResults.AspNetCore.OpenApi.csproj` carries its own `PackageReleaseNotes` introducing the package, its opt-in `AddPortableResultsOpenApi` entry point, the canonical schema catalog, the three helpers, the three attributes, and the error-metadata registry. +- [x] Automated tests cover the document transformer end-to-end: canonical catalog emission, each helper/attribute's effect on the generated document, global error-code registry integration, inline escape hatch, per-endpoint format override, success-response metadata narrowing, and the fallback `$ref` for undocumented codes. +- [x] The `NativeAotMovieRating` sample is updated to the new public API (adds a `ProjectReference` to `Light.PortableResults.AspNetCore.OpenApi`, calls `AddPortableResultsOpenApi()`, uses the new helpers) and no longer wires `OpenApiOptions.CreateSchemaReferenceId`. +- [x] The new OpenAPI surface remains NativeAOT-compatible. The `NativeAotMovieRating` sample continues to build and run under `PublishAot=true`, and the document transformer uses only APIs compatible with the trimmer and AOT analyzer (no `Type.MakeGenericType`, no dynamic assembly emit, no reflection over handler parameters; all generic dispatch happens through the existing `GetOrCreateSchemaAsync` API and through attribute instances supplied at compile time). +- [x] `README.md` is updated: the OpenAPI section reflects the new public surface (new `Light.PortableResults.AspNetCore.OpenApi` package, opt-in `AddPortableResultsOpenApi()`, three helpers, three attributes, DI-level `ConfigureErrorMetadataContracts`, per-endpoint format override), and all references to the deleted schema-only CLR types, the naming convention, and the `Rich` vs `AspNetCoreCompatible` helper split are removed. ## Technical Details diff --git a/ai-plans/0040-2-validation-error-contracts.md b/ai-plans/0040-2-validation-error-contracts.md new file mode 100644 index 0000000..faeed6c --- /dev/null +++ b/ai-plans/0040-2-validation-error-contracts.md @@ -0,0 +1,86 @@ +# Built-In Validation Error Contracts for OpenAPI + +## Rationale + +Plan `0040-1-openapi-redesign.md` introduces `IPortableErrorMetadataContractRegistry`, which maps error code strings to CLR metadata types so the OpenAPI document transformer can narrow `errors[*].metadata` and `errorDetails[*].metadata` to accurate schemas per code. + +The `Light.PortableResults.Validation` package already defines a stable code-plus-metadata taxonomy through its built-in `ValidationErrorDefinition` subclasses (`CountValidationErrorDefinition`, `MinCountValidationErrorDefinition`, `GreaterThanValidationErrorDefinition`, `RegexValidationErrorDefinition`, `EnumNameValidationErrorDefinition`, `PrecisionScaleValidationErrorDefinition`, etc.). The metadata keys are centralized in `ValidationErrorMetadataKeys`. Without this follow-up, every caller who uses built-in validation error definitions has to redeclare contracts the library already owns. + +Two aspects of the built-in contracts make a pure CLR-type registration awkward: + +1. **Polymorphic primitive values.** `CreateMetadataValue` in `BuiltInValidationErrorDefinitions.Shared.cs` projects any `T` down to one of `null | boolean | int64 | double | decimal | string` for primitives. A code like `GreaterThan` is used for integers, dates, strings, and more \u2014 the metadata schema for `comparativeValue` is honestly a JSON-primitive union, not a single CLR type. +2. **Package boundary.** `Light.PortableResults.AspNetCore.OpenApi` (where `IPortableErrorMetadataContractRegistry` lives, per `0040-1`) does not and should not depend on `Light.PortableResults.Validation`. The built-in contracts must live in the validation package and opt in from there. + +This plan widens the registry to also accept pre-authored `OpenApiSchema` values, ships a catalog of canonical schemas for every built-in validation error code that carries metadata, and adds a one-line opt-in extension. It also exposes the built-in codes as compile-time constants so callers get IntelliSense and refactor safety when opting into specific codes. + +## Acceptance Criteria + +- [ ] `PortableErrorMetadataContract` is introduced as a public readonly struct in `Light.PortableResults.AspNetCore.OpenApi` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of either a CLR `Type` (to be run through the ASP.NET Core schema generator) or a pre-authored `OpenApiSchema`. It exposes `static FromType(Type metadataType)`, `static FromSchema(OpenApiSchema metadataSchema)`, a `Kind` enum property (`Type` / `Schema`), and `TryGetType(out Type)` / `TryGetSchema(out OpenApiSchema)` accessors. +- [ ] `IPortableErrorMetadataContractRegistry.Contracts` is widened from `IReadOnlyDictionary` to `IReadOnlyDictionary`. The default implementation and its tests are updated accordingly. +- [ ] `PortableErrorMetadataContractsBuilder` gains a new overload `ForCode(string code, OpenApiSchema metadataSchema)`. The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. +- [ ] `PortableResultsOpenApiDocumentTransformer` is updated to dispatch on `PortableErrorMetadataContract.Kind` when materializing registry entries into `Components.Schemas`: `Type` entries go through the ASP.NET Core schema generator as before, `Schema` entries are installed directly. +- [ ] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains hand-authored canonical schemas for every built-in validation error code that carries metadata (`Count`, `MinCount`, `MaxCount`, `Length`, `MinLength`, `MaxLength`, `LengthInRange`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`), using the exact JSON property names defined in `ValidationErrorMetadataKeys`. +- [ ] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives — `{ type: string }`, `{ type: number }`, `{ type: integer }`, `{ type: boolean }`, `{ type: "null" }` — matching what `CreateMetadataValue` actually produces on the wire. +- [ ] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (e.g. `Count`, `MinCount`, `GreaterThan`, `Pattern`, `EnumName`, `PrecisionScale`, `NotNull`, `NotEmpty`, `Empty`, `Predicate`, ...). The constant values match the code strings assigned in the built-in definition constructors exactly. The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. +- [ ] A public extension method `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder builder)` is added in `Light.PortableResults.Validation`. It iterates `BuiltInValidationErrorContracts.Contracts` and calls the new `ForCode(string, OpenApiSchema)` overload for each entry. Codes without metadata (for example `NotNull`, `NotEmpty`, `Empty`, `Predicate`) are intentionally not registered because there is no metadata shape to narrow. +- [ ] `Light.PortableResults.Validation.csproj` adds a package reference to `Microsoft.OpenApi` (the `Microsoft.OpenApi.Models` types only — no ASP.NET Core dependency; the validation package must remain usable from non-ASP.NET Core hosts). The package targets `netstandard2.0` so this reference must be compatible with that target. A corresponding `` entry is added to `Directory.Packages.props`. +- [ ] The `NativeAotMovieRating` sample is updated to call `.RegisterBuiltInValidationErrors()` inside `ConfigureErrorMetadataContracts` and to opt its endpoints into the relevant built-in codes via `WithErrorCodes(ValidationErrorCodes.Count, ...)`. +- [ ] Automated tests cover: the discriminated-union behavior of `PortableErrorMetadataContract`, the schema output for every built-in code (round-tripped against the taxonomy in `ValidationErrorMetadataKeys`), the `oneOf`-over-primitives shape for typed-value codes, the `RegisterBuiltInValidationErrors` extension registering the expected set of codes, and an end-to-end scenario where an endpoint opts into a built-in code and the generated OpenAPI document contains the narrowed schema. +- [ ] `README.md` is updated to describe the opt-in one-liner, the built-in taxonomy surfaced by `ValidationErrorCodes`, and the fact that user-defined codes continue to register through the existing type-based overloads. + +## Technical Details + +### Contract Widening + +`PortableErrorMetadataContract` is a small readonly struct, not an interface, so the hot path stays allocation-free. The default implementation of `IPortableErrorMetadataContractRegistry` stores entries in a `Dictionary`. Existing call sites that wrote `Type` directly are updated to wrap with `PortableErrorMetadataContract.FromType(...)`. The public `ForCode(string)` and `ForCode(string, Type)` overloads are unchanged. + +The new `ForCode(string code, OpenApiSchema metadataSchema)` overload clones the provided schema defensively (using `OpenApiSchema`'s copy constructor) so later mutations by callers do not leak into the registry. + +### Transformer Dispatch + +When the transformer synthesizes the canonical `PortableError_` and `PortableValidationErrorDetail_` schemas, it reads the contract and: + +- For `PortableErrorMetadataContract.Kind == Type`, runs the CLR type through the ASP.NET Core schema generator exposed by `OpenApiDocumentTransformerContext` (unchanged behavior). +- For `PortableErrorMetadataContract.Kind == Schema`, installs the provided schema under the name `Metadata` (if not already present) and `$ref`s it from the narrowed code schema. + +Naming for schema-based contracts uses `Metadata` (for example `CountMetadata`, `GreaterThanMetadata`) so they are discoverable in generated client code and do not collide with user types. + +### Built-In Contract Catalog + +`BuiltInValidationErrorContracts.Contracts` is built once (static readonly). Each entry authors an `OpenApiSchema` with `Type = "object"`, the exact property keys from `ValidationErrorMetadataKeys`, and `Required` populated to match. Examples of the authored shapes: + +- `Count` \u2192 `{ expectedCount: integer }`. +- `MinCount` \u2192 `{ minCount: integer }`. +- `MaxCount` \u2192 `{ maxCount: integer }`. +- `Length` / `MinLength` / `MaxLength` \u2192 analogous integer properties. +- `LengthInRange` \u2192 `{ minLength: integer, maxLength: integer }`. +- `GreaterThan` / `GreaterThanOrEqualTo` / `LessThan` / `LessThanOrEqualTo` \u2192 `{ comparativeValue: }`. +- `InRange` \u2192 `{ lowerBoundary: , upperBoundary: }`. +- `Pattern` \u2192 `{ pattern: string, regexOptions: integer }`. +- `Enum` \u2192 `{ enumType: string }`. +- `EnumName` \u2192 `{ enumType: string, ignoreCase: boolean }`. +- `PrecisionScale` \u2192 `{ expectedPrecision: integer, expectedScale: integer, ignoreTrailingZeros: boolean }`. + +The `` shape is a shared helper schema referenced by `$ref` to avoid duplication: + +```text +MetadataPrimitiveValue: + oneOf: + - { type: string } + - { type: number } + - { type: integer } + - { type: boolean } + - { type: "null" } +``` + +`MetadataPrimitiveValue` is registered alongside the per-code schemas and reused wherever a typed primitive metadata value appears. + +### Package Wiring + +`Light.PortableResults.Validation` takes on a `Microsoft.OpenApi` package reference to author `OpenApiSchema` instances. The reference is compile-time only; the validation package does not reference `Microsoft.AspNetCore.OpenApi` or `Light.PortableResults.AspNetCore.OpenApi` and remains usable from non-ASP.NET Core hosts — `RegisterBuiltInValidationErrors` is an extension on `PortableErrorMetadataContractsBuilder` (declared in `Light.PortableResults.AspNetCore.OpenApi`), so callers who pull in the validation package without the OpenAPI package simply never see the extension in scope. The `RegisterBuiltInValidationErrors` extension method lives in a file under the same namespace as `BuiltInValidationErrorContracts` so a single `using` import exposes the opt-in along with the constants. + +### Scope Boundaries + +- This plan does not auto-register the built-in contracts from `AddPortableResultsForMinimalApis` / `AddPortableResultsForMvc`. The opt-in is explicit so that consumers who do not use the validation package, or who use custom message templates with bespoke codes, are not forced into an extra contract catalog. +- This plan does not unify `IPortableErrorMetadataContractRegistry` with `IValidationErrorDefinitionCache`. They cover different concerns (documentation vs runtime messaging) and any unification is a separate refactor. +- This plan does not ship CLR DTO types that mirror the built-in metadata shapes. Pre-authored `OpenApiSchema` instances are the canonical representation for these polymorphic contracts. diff --git a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs index 72f716f..0f09b02 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs +++ b/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs @@ -1,6 +1,8 @@ using System.Threading; using System.Threading.Tasks; using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using NativeAotMovieRating.InMemoryDatabaseAccess; @@ -19,7 +21,7 @@ public static void MapAddMovieRatingEndpoint(this WebApplication app) => "Validates the request and stores the movie rating. Returns the stored rating on success, or a rich Light.PortableResults problem details response on validation or lookup failures." ) .Produces() - .ProducesPortableRichValidationProblem() + .ProducesPortableValidationProblem(configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)) .ProducesPortableProblem(); private static async Task AddMovieRating( diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index 31b1788..19b874b 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using Light.PortableResults; using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; using Light.PortableResults.Metadata; using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; @@ -26,7 +28,7 @@ public static void MapGetMoviesEndpoint(this WebApplication app) => "details response when input validation fails or when the lastKnownMovieId does not exist." ) .Produces>() - .ProducesPortableRichValidationProblem(); + .ProducesPortableValidationProblem(configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)); private static async Task GetMovies( IGetMoviesSession session, diff --git a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs index cbfd60a..80eaf91 100644 --- a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs +++ b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; -using Light.PortableResults.AspNetCore.Shared; using Light.PortableResults.Http.Writing; using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.InMemoryDatabaseAccess; @@ -19,8 +18,4 @@ namespace NativeAotMovieRating.JsonSerialization; // JSON (AOT mode) will not resolve them implicitly. [JsonSerializable(typeof(Guid?))] [JsonSerializable(typeof(int))] -// Schema-only types registered so Microsoft.AspNetCore.OpenApi can emit JSON schemas under -// the AOT-friendly source-gen serializer options configured for this app. -[JsonSerializable(typeof(PortableRichValidationProblemDetails))] -[JsonSerializable(typeof(PortableProblemDetails))] public sealed partial class MovieRatingJsonContext : JsonSerializerContext; diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index 28becf0..d01484b 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -4,13 +4,15 @@ true true false + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated true - - + + + diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index a3142a0..2304b69 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -1,5 +1,5 @@ using Light.PortableResults.AspNetCore.MinimalApis; -using Light.PortableResults.AspNetCore.Shared; +using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.OpenApi; @@ -21,6 +21,7 @@ builder .Services .AddPortableResultsForMinimalApis() + .AddPortableResultsOpenApi() .AddValidationForPortableResults() .ConfigureJsonSerialization() .AddInMemoryDatabase() @@ -28,11 +29,7 @@ .AddAddMovieRatingModule() .AddHealthChecks() .Services - .AddOpenApi( - options => options.CreateSchemaReferenceId = type => - PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(type) ?? - OpenApiOptions.CreateDefaultSchemaReferenceId(type) - ); + .AddOpenApi(); var app = builder.Build(); app.UseSerilogRequestLogging(); diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 02f29b6..a2ee1c7 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -144,6 +144,13 @@ "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" } }, + "light.portableresults.aspnetcore.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj b/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj index d6d256f..a942038 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj @@ -9,6 +9,7 @@ - LightResult and LightResult<T> and corresponding extension methods to turn result instances into HTTP success responses or RFC 9457 (and RFC 7807) compatible Problem Details responses. - Easy integration into ASP.NET Core's composition root via IServiceCollection.AddPortableResultsForMinimalApis. + - OpenAPI helpers and schema-only CLR surrogate types were removed; use Light.PortableResults.AspNetCore.OpenApi for OpenAPI integration. - Compatible with .NET Native AOT. diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs b/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs deleted file mode 100644 index 135a329..0000000 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace Light.PortableResults.AspNetCore.MinimalApis; - -/// -/// Extension methods that add OpenAPI response metadata for Light.PortableResults endpoints. -/// These helpers are documentation-only: they register schema-only CLR types with the endpoint so -/// OpenAPI generators can emit accurate response schemas. The runtime HTTP serialization behavior is -/// unaffected. -/// -public static class PortableResultsEndpointExtensions -{ - /// - /// Documents a successful response whose body contains both a and - /// a metadata object. The response type is documented as - /// . - /// - /// The type of the success value. - /// The type of the success metadata. - /// The route handler builder. - /// The HTTP status code (default 200). - /// The content type (default "application/json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableSuccessResponse( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) => - builder.Produces>(statusCode, contentType); - - /// - /// Documents a Light.PortableResults problem details failure response with untyped metadata. - /// Use the relevant (e.g. 401, 403, 404, 409, 500) to document - /// the expected non-validation failure response for the endpoint. - /// - /// The route handler builder. - /// The HTTP status code (default 500 Internal Server Error). - /// The content type (default "application/problem+json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableProblem( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status500InternalServerError, - string contentType = "application/problem+json" - ) => - builder.Produces(statusCode, contentType); - - /// - /// Documents a Light.PortableResults problem details failure response with strongly typed metadata. - /// Use the relevant to document the expected non-validation failure - /// response for the endpoint. - /// - /// The type of the metadata on each error. - /// The type of the top-level problem metadata. - /// The route handler builder. - /// The HTTP status code (default 500 Internal Server Error). - /// The content type (default "application/problem+json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableProblem( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status500InternalServerError, - string contentType = "application/problem+json" - ) => - builder.Produces>(statusCode, contentType); - - /// - /// Documents a rich Light.PortableResults validation problem details response with untyped metadata. - /// Use this helper when ValidationProblemSerializationFormat is set to - /// Rich. - /// - /// The route handler builder. - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableRichValidationProblem( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) => - builder.Produces(statusCode, contentType); - - /// - /// Documents a rich Light.PortableResults validation problem details response with strongly typed - /// metadata. Use this helper when ValidationProblemSerializationFormat is set to - /// Rich. - /// - /// The type of the metadata on each validation error. - /// The type of the top-level problem metadata. - /// The route handler builder. - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableRichValidationProblem( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) => - builder.Produces>( - statusCode, - contentType - ); - - /// - /// Documents an ASP.NET Core-compatible Light.PortableResults validation problem details response with - /// untyped metadata. Use this helper when ValidationProblemSerializationFormat is set to - /// AspNetCoreCompatible. - /// - /// The route handler builder. - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) => - builder.Produces(statusCode, contentType); - - /// - /// Documents an ASP.NET Core-compatible Light.PortableResults validation problem details response with - /// strongly typed metadata. Use this helper when ValidationProblemSerializationFormat is set to - /// AspNetCoreCompatible. - /// - /// The type of the metadata on each error details entry. - /// The type of the top-level problem metadata. - /// The route handler builder. - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - /// The route handler builder for chaining. - public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem< - TErrorDetailMetadata, - TProblemMetadata - >( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) => - builder.Produces>( - statusCode, - contentType - ); -} diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json index 291eb2b..9c4a2fc 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net10.0": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" + }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", "requested": "[10.0.3, )", diff --git a/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj b/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj index b0bca29..0cd327d 100644 --- a/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj +++ b/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj @@ -8,6 +8,7 @@ - LightActionResult and LightActionResult<T> and corresponding extension methods to turn result instances into HTTP success responses or RFC 9457 (and RFC 7807) compatible Problem Details responses. - Easy integration into ASP.NET Core's composition root via IServiceCollection.AddPortableResultsForMvc. + - OpenAPI attributes and schema-only CLR surrogate types were removed; use Light.PortableResults.AspNetCore.OpenApi for OpenAPI integration. diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs deleted file mode 100644 index 4a4c8b8..0000000 --- a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableAspNetCoreValidationProblemAttribute.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Mvc; - -/// -/// Specifies that the action produces an ASP.NET Core-compatible Light.PortableResults validation -/// problem details response with untyped metadata. Use this attribute when -/// ValidationProblemSerializationFormat is set to AspNetCoreCompatible. The response -/// type is documented as . -/// -public sealed class ProducesPortableAspNetCoreValidationProblemAttribute : - ProducesResponseTypeAttribute -{ - /// - /// Initializes a new instance of - /// . - /// - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - public ProducesPortableAspNetCoreValidationProblemAttribute( - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) : base(statusCode, contentType) { } -} - -/// -/// Specifies that the action produces an ASP.NET Core-compatible Light.PortableResults validation -/// problem details response with strongly typed metadata. Use this attribute when -/// ValidationProblemSerializationFormat is set to AspNetCoreCompatible. The response -/// type is documented as -/// . -/// -/// The type of the metadata on each error details entry. -/// The type of the top-level problem metadata. -public sealed class ProducesPortableAspNetCoreValidationProblemAttribute< - TErrorDetailMetadata, - TProblemMetadata -> : ProducesResponseTypeAttribute< - PortableAspNetCoreValidationProblemDetails -> -{ - /// - /// Initializes a new instance of - /// . - /// - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - public ProducesPortableAspNetCoreValidationProblemAttribute( - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) : base(statusCode, contentType) { } -} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs deleted file mode 100644 index ea72530..0000000 --- a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableProblemAttribute.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Mvc; - -/// -/// Specifies that the action produces a Light.PortableResults problem details failure response with -/// untyped metadata. The response type is documented as . -/// -public sealed class ProducesPortableProblemAttribute : ProducesResponseTypeAttribute -{ - /// - /// Initializes a new instance of . - /// - /// The HTTP status code (default 500 Internal Server Error). - /// The content type (default "application/problem+json"). - public ProducesPortableProblemAttribute( - int statusCode = StatusCodes.Status500InternalServerError, - string contentType = "application/problem+json" - ) : base(statusCode, contentType) { } -} - -/// -/// Specifies that the action produces a Light.PortableResults problem details failure response with -/// strongly typed metadata. The response type is documented as -/// . -/// -/// The type of the metadata on each error. -/// The type of the top-level problem metadata. -public sealed class ProducesPortableProblemAttribute : - ProducesResponseTypeAttribute> -{ - /// - /// Initializes a new instance of - /// . - /// - /// The HTTP status code (default 500 Internal Server Error). - /// The content type (default "application/problem+json"). - public ProducesPortableProblemAttribute( - int statusCode = StatusCodes.Status500InternalServerError, - string contentType = "application/problem+json" - ) : base(statusCode, contentType) { } -} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs deleted file mode 100644 index b405c00..0000000 --- a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableRichValidationProblemAttribute.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Mvc; - -/// -/// Specifies that the action produces a rich Light.PortableResults validation problem details response -/// with untyped metadata. Use this attribute when ValidationProblemSerializationFormat is set -/// to Rich. The response type is documented as -/// . -/// -public sealed class ProducesPortableRichValidationProblemAttribute : - ProducesResponseTypeAttribute -{ - /// - /// Initializes a new instance of . - /// - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - public ProducesPortableRichValidationProblemAttribute( - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) : base(statusCode, contentType) { } -} - -/// -/// Specifies that the action produces a rich Light.PortableResults validation problem details response -/// with strongly typed metadata. Use this attribute when ValidationProblemSerializationFormat -/// is set to Rich. The response type is documented as -/// . -/// -/// The type of the metadata on each validation error. -/// The type of the top-level problem metadata. -public sealed class ProducesPortableRichValidationProblemAttribute : - ProducesResponseTypeAttribute> -{ - /// - /// Initializes a new instance of - /// . - /// - /// The HTTP status code (default 400 Bad Request). - /// The content type (default "application/problem+json"). - public ProducesPortableRichValidationProblemAttribute( - int statusCode = StatusCodes.Status400BadRequest, - string contentType = "application/problem+json" - ) : base(statusCode, contentType) { } -} diff --git a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs b/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs deleted file mode 100644 index 52d841f..0000000 --- a/src/Light.PortableResults.AspNetCore.Mvc/ProducesPortableSuccessResponseAttribute.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Mvc; - -/// -/// Specifies the response type for a successful action whose body contains both a -/// and a metadata object. The response -/// type is documented as for OpenAPI purposes. -/// -/// The type of the success value. -/// The type of the success metadata. -public sealed class ProducesPortableSuccessResponseAttribute : - ProducesResponseTypeAttribute> -{ - /// - /// Initializes a new instance of . - /// - /// The HTTP status code (default 200). - /// The content type (default "application/json"). - public ProducesPortableSuccessResponseAttribute( - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) : base(statusCode, contentType) { } -} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs new file mode 100644 index 0000000..e19805a --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Provides the global map of documented error-code metadata contracts. +/// +public interface IPortableErrorMetadataContractRegistry +{ + /// + /// Gets the immutable map of documented error codes to their metadata CLR types. + /// + IReadOnlyDictionary Contracts { get; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs b/src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs new file mode 100644 index 0000000..74391ba --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs @@ -0,0 +1,11 @@ +using System; +using Light.PortableResults.SharedJsonSerialization; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +internal interface IPortableSuccessResponseOpenApiAttribute +{ + Type ValueType { get; } + MetadataSerializationMode MetadataSerializationMode { get; } + bool HasMetadataSerializationModeOverride { get; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj b/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj new file mode 100644 index 0000000..522e44c --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj @@ -0,0 +1,23 @@ + + + + true + Opt-in OpenAPI integration package for Light.PortableResults ASP.NET Core applications. Provides a library-authored schema catalog, endpoint metadata attributes, Minimal API helpers, and a document transformer. + + Light.PortableResults.AspNetCore.OpenApi 0.4.0 + ------------------------------------- + + - Opt-in OpenAPI integration via IServiceCollection.AddPortableResultsOpenApi. + - Library-authored canonical schema catalog and a document transformer for Minimal APIs and MVC. + - Three public OpenAPI helpers, three public attributes, and a global error-metadata registry. + - Native AOT compatible OpenAPI support without schema-only CLR surrogate types. + + + + + + + + + + diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs new file mode 100644 index 0000000..49eba72 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Default implementation of . +/// +public sealed class PortableErrorMetadataContractRegistry : IPortableErrorMetadataContractRegistry +{ + /// + /// Initializes a new instance of . + /// + /// The builder that holds the configured contracts. + public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var contracts = new Dictionary(StringComparer.Ordinal); + var sanitizedCodes = new Dictionary(StringComparer.Ordinal); + foreach (var (code, metadataType) in builder.Contracts) + { + if (contracts.TryGetValue(code, out var existingType)) + { + if (existingType == metadataType) + { + continue; + } + + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingType, + metadataType + ) + ); + } + + var sanitizedCode = PortableResultsOpenApiSchemaNaming.SanitizeErrorCode(code); + if (sanitizedCodes.TryGetValue(sanitizedCode, out var existingCode)) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateSanitizedErrorCodeCollisionMessage( + existingCode, + code, + sanitizedCode + ) + ); + } + + contracts.Add(code, metadataType); + sanitizedCodes.Add(sanitizedCode, code); + } + + Contracts = new ReadOnlyDictionary(contracts); + } + + /// + public IReadOnlyDictionary Contracts { get; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs new file mode 100644 index 0000000..a43f7dc --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Builds the global map of documented error-code metadata contracts. +/// +public sealed class PortableErrorMetadataContractsBuilder +{ + private readonly Dictionary _contracts = new (StringComparer.Ordinal); + private readonly Dictionary _sanitizedCodes = new (StringComparer.Ordinal); + + internal IReadOnlyDictionary Contracts => _contracts; + + /// + /// Registers as the metadata contract for the specified code. + /// + public PortableErrorMetadataContractsBuilder ForCode(string code) + { + return ForCode(code, typeof(TMetadata)); + } + + /// + /// Registers the specified CLR metadata type for the specified code. + /// + public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(metadataType); + + if (_contracts.TryGetValue(code, out var existingType)) + { + if (existingType == metadataType) + { + return this; + } + + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingType, + metadataType + ) + ); + } + + var sanitizedCode = PortableResultsOpenApiSchemaNaming.SanitizeErrorCode(code); + if (_sanitizedCodes.TryGetValue(sanitizedCode, out var existingRawCode)) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateSanitizedErrorCodeCollisionMessage( + existingRawCode, + code, + sanitizedCode + ) + ); + } + + _contracts.Add(code, metadataType); + _sanitizedCodes.Add(sanitizedCode, code); + return this; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs new file mode 100644 index 0000000..7b5ffa1 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs @@ -0,0 +1,12 @@ +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Options backing the global error-code metadata contract registry. +/// +public sealed class PortableErrorMetadataContractsOptions +{ + /// + /// Gets the mutable builder populated through the options pipeline. + /// + public PortableErrorMetadataContractsBuilder Builder { get; } = new (); +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs new file mode 100644 index 0000000..5aa3dc9 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs @@ -0,0 +1,58 @@ +using System; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +internal static class PortableOpenApiBuilderUtilities +{ + internal static string[] AppendStrings(string[]? existingValues, string newValue) + { + ArgumentNullException.ThrowIfNull(newValue); + + if (existingValues is null) + { + return [newValue]; + } + + var combinedValues = new string[existingValues.Length + 1]; + Array.Copy(existingValues, combinedValues, existingValues.Length); + combinedValues[^1] = newValue; + return combinedValues; + } + + internal static string[] AppendStrings(string[]? existingValues, string[] newValues) + { + ArgumentNullException.ThrowIfNull(newValues); + + if (newValues.Length == 0) + { + return existingValues ?? []; + } + + if (existingValues is null) + { + var copy = new string[newValues.Length]; + Array.Copy(newValues, copy, newValues.Length); + return copy; + } + + var combinedValues = new string[existingValues.Length + newValues.Length]; + Array.Copy(existingValues, combinedValues, existingValues.Length); + Array.Copy(newValues, 0, combinedValues, existingValues.Length, newValues.Length); + return combinedValues; + } + + internal static Type[] AppendTypes(Type[]? existingValues, Type newValue) + { + ArgumentNullException.ThrowIfNull(newValue); + + if (existingValues is null) + { + return [newValue]; + } + + var combinedValues = new Type[existingValues.Length + 1]; + Array.Copy(existingValues, combinedValues, existingValues.Length); + combinedValues[^1] = newValue; + return combinedValues; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs new file mode 100644 index 0000000..6b156ac --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs @@ -0,0 +1,81 @@ +using System; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Base class for Light.PortableResults OpenAPI response metadata. +/// +public abstract class PortableOpenApiResponseAttributeBase : Attribute +{ + /// + /// Initializes a new instance of . + /// + /// The response kind documented by the attribute. + /// The associated HTTP status code. + /// The associated content type. + protected PortableOpenApiResponseAttributeBase( + PortableOpenApiResponseKind kind, + int statusCode, + string contentType + ) + { + ArgumentNullException.ThrowIfNull(contentType); + Kind = kind; + StatusCode = statusCode; + ContentType = contentType; + } + + /// + /// Gets the documented response kind. + /// + public PortableOpenApiResponseKind Kind { get; } + + /// + /// Gets or sets the HTTP status code. + /// + public int StatusCode { get; set; } + + /// + /// Gets or sets the documented content type. + /// + public string ContentType { get; set; } + + /// + /// Gets or sets the optional CLR type used to narrow the top-level metadata schema. + /// + public Type? TopLevelMetadataType { get; set; } +} + +/// +/// Base class for Light.PortableResults error-response OpenAPI metadata. +/// +public abstract class PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase +{ + /// + /// Initializes a new instance of . + /// + /// The response kind documented by the attribute. + /// The associated HTTP status code. + /// The associated content type. + protected PortableOpenApiErrorResponseAttributeBase( + PortableOpenApiResponseKind kind, + int statusCode, + string contentType + ) : base(kind, statusCode, contentType) { } + + /// + /// Gets or sets the globally registered error codes that should be narrowed on the response. + /// + public string[]? ErrorCodes { get; set; } + + /// + /// Gets or sets the inline error codes whose metadata schema is defined directly on the endpoint. + /// + public string[]? InlineErrorMetadataCodes { get; set; } + + /// + /// Gets or sets the inline metadata CLR types aligned by index with + /// . + /// + public Type[]? InlineErrorMetadataTypes { get; set; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseKind.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseKind.cs new file mode 100644 index 0000000..2093591 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseKind.cs @@ -0,0 +1,22 @@ +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Identifies the documented Light.PortableResults HTTP response shape. +/// +public enum PortableOpenApiResponseKind +{ + /// + /// A successful response that may serialize a bare value or a value-plus-metadata envelope. + /// + SuccessResponse = 0, + + /// + /// A non-validation RFC 9457 problem details response. + /// + Problem = 1, + + /// + /// A validation problem details response. + /// + ValidationProblem = 2 +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs new file mode 100644 index 0000000..bc9128c --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -0,0 +1,68 @@ +using System; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Configures a documented Light.PortableResults problem response. +/// +public sealed class PortableProblemOpenApiBuilder +{ + private readonly ProducesPortableProblemAttribute _attribute; + + internal PortableProblemOpenApiBuilder(ProducesPortableProblemAttribute attribute) => _attribute = attribute; + + /// + /// Narrows the top-level metadata schema to . + /// + public PortableProblemOpenApiBuilder WithMetadata() + { + _attribute.TopLevelMetadataType = typeof(TMetadata); + return this; + } + + /// + /// Narrows the top-level metadata schema to the specified CLR type. + /// + public PortableProblemOpenApiBuilder WithMetadata(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + _attribute.TopLevelMetadataType = metadataType; + return this; + } + + /// + /// Opts the response into globally registered metadata contracts for the provided error codes. + /// + public PortableProblemOpenApiBuilder WithErrorCodes(params string[] codes) + { + _attribute.ErrorCodes = PortableOpenApiBuilderUtilities.AppendStrings(_attribute.ErrorCodes, codes); + return this; + } + + /// + /// Registers an inline metadata contract for the specified error code. + /// + public PortableProblemOpenApiBuilder WithErrorMetadata(string code, Type metadataType) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(metadataType); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataTypes = PortableOpenApiBuilderUtilities.AppendTypes( + _attribute.InlineErrorMetadataTypes, + metadataType + ); + return this; + } + + /// + /// Registers an inline metadata contract for the specified error code. + /// + public PortableProblemOpenApiBuilder WithErrorMetadata(string code) + { + return WithErrorMetadata(code, typeof(TMetadata)); + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs new file mode 100644 index 0000000..3824fed --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.SharedJsonSerialization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// OpenAPI document transformer for Light.PortableResults. +/// +public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocumentTransformer +{ + private readonly IOptions _writeOptions; + private readonly IPortableErrorMetadataContractRegistry _errorMetadataContractRegistry; + + /// + /// Initializes a new instance of . + /// + public PortableResultsOpenApiDocumentTransformer( + IOptions writeOptions, + IPortableErrorMetadataContractRegistry errorMetadataContractRegistry + ) + { + _writeOptions = writeOptions; + _errorMetadataContractRegistry = errorMetadataContractRegistry; + } + + /// + public async Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(context); + + PortableResultsOpenApiSchemas.InstallInto(document); + var openApiOptionsMonitor = context.ApplicationServices.GetRequiredService>(); + var openApiVersion = openApiOptionsMonitor.Get(context.DocumentName).OpenApiVersion; + await EnsureGlobalErrorContractSchemasAsync(document, context, openApiVersion, cancellationToken); + + if (document.Paths is null) + { + return; + } + + foreach (var apiDescription in context.DescriptionGroups.SelectMany(static group => group.Items)) + { + var attributes = apiDescription.ActionDescriptor.EndpointMetadata? + .OfType() + .ToArray() ?? + []; + if (attributes.Length == 0) + { + continue; + } + + if (!TryGetOperation(document, apiDescription, out var operation)) + { + continue; + } + + await ApplyResponseMetadataAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attributes, + cancellationToken + ); + } + } + + private async Task EnsureGlobalErrorContractSchemasAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + OpenApiSpecVersion openApiVersion, + CancellationToken cancellationToken + ) + { + foreach (var (errorCode, metadataType) in _errorMetadataContractRegistry.Contracts) + { + var portableErrorSchemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId( + PortableResultsOpenApiSchemas.PortableErrorSchemaId, + errorCode + ); + await EnsureCodeSpecificSchemaAsync( + document, + context, + PortableResultsOpenApiSchemas.PortableErrorSchemaId, + portableErrorSchemaId, + errorCode, + metadataType, + openApiVersion, + cancellationToken + ); + + var validationErrorSchemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId( + PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId, + errorCode + ); + await EnsureCodeSpecificSchemaAsync( + document, + context, + PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId, + validationErrorSchemaId, + errorCode, + metadataType, + openApiVersion, + cancellationToken + ); + } + } + + private async Task ApplyResponseMetadataAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + OpenApiSpecVersion openApiVersion, + ApiDescription apiDescription, + OpenApiOperation operation, + IReadOnlyList attributes, + CancellationToken cancellationToken + ) + { + var responseGroups = attributes.GroupBy(static attribute => new ResponseGroupKey(attribute.StatusCode, attribute.ContentType)); + foreach (var responseGroup in responseGroups) + { + foreach (var duplicateGroup in responseGroup.GroupBy(static attribute => attribute.Kind)) + { + if (duplicateGroup.Count() <= 1) + { + continue; + } + + throw new InvalidOperationException( + $"The OpenAPI response metadata for status code {responseGroup.Key.StatusCode} and content type '{responseGroup.Key.ContentType}' contains multiple markers of kind '{duplicateGroup.Key}'." + ); + } + + var contributingSchemas = new List(responseGroup.Count()); + foreach (var attribute in responseGroup) + { + var schema = await CreateContributingSchemaAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attribute, + cancellationToken + ); + contributingSchemas.Add(schema); + } + + var response = GetOrCreateResponse(operation, responseGroup.Key.StatusCode); + response.Content ??= new Dictionary(StringComparer.Ordinal); + response.Content[responseGroup.Key.ContentType] = new OpenApiMediaType + { + Schema = contributingSchemas.Count == 1 + ? contributingSchemas[0] + : new OpenApiSchema { AnyOf = contributingSchemas } + }; + } + } + + private async Task CreateContributingSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + OpenApiSpecVersion openApiVersion, + ApiDescription apiDescription, + OpenApiOperation operation, + PortableOpenApiResponseAttributeBase attribute, + CancellationToken cancellationToken + ) + { + return attribute.Kind switch + { + PortableOpenApiResponseKind.SuccessResponse => + await CreateSuccessResponseSchemaAsync( + document, + context, + apiDescription, + operation, + attribute, + cancellationToken + ), + PortableOpenApiResponseKind.Problem or PortableOpenApiResponseKind.ValidationProblem => + await CreateErrorResponseSchemaAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attribute, + cancellationToken + ), + _ => throw new InvalidOperationException($"The response kind '{attribute.Kind}' is not supported.") + }; + } + + private async Task CreateSuccessResponseSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + ApiDescription apiDescription, + OpenApiOperation operation, + PortableOpenApiResponseAttributeBase attribute, + CancellationToken cancellationToken + ) + { + if (attribute is not IPortableSuccessResponseOpenApiAttribute successAttribute) + { + throw new InvalidOperationException( + $"The response attribute '{attribute.GetType().FullName}' does not expose success-response metadata." + ); + } + + var metadataSerializationMode = successAttribute.HasMetadataSerializationModeOverride + ? successAttribute.MetadataSerializationMode + : _writeOptions.Value.MetadataSerializationMode; + if (attribute.TopLevelMetadataType is not null && + metadataSerializationMode == MetadataSerializationMode.ErrorsOnly) + { + throw new InvalidOperationException( + "Top-level success metadata cannot be documented when MetadataSerializationMode is ErrorsOnly because metadata is not part of the wire format." + ); + } + + var valueSchema = await context.GetOrCreateSchemaAsync( + successAttribute.ValueType, + parameterDescription: null, + cancellationToken + ); + + if (metadataSerializationMode == MetadataSerializationMode.ErrorsOnly && + attribute.TopLevelMetadataType is null) + { + return valueSchema; + } + + var envelopeSchemaId = PortableResultsOpenApiSchemaNaming.CreateDerivedEnvelopeSchemaId( + "PortableSuccessResponse", + operation, + apiDescription, + attribute.StatusCode, + attribute.ContentType + ); + IOpenApiSchema metadataSchema = attribute.TopLevelMetadataType is null + ? PortableResultsOpenApiSchemas.CreateOpenMetadataSchema() + : await GetStableSchemaReferenceAsync( + document, + context, + attribute.TopLevelMetadataType, + PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(envelopeSchemaId), + cancellationToken + ); + + var envelopeSchema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["value"] = valueSchema, + ["metadata"] = metadataSchema + }, + Required = new HashSet(StringComparer.Ordinal) { "value" } + }; + return AddComponentAndCreateReference(document, envelopeSchemaId, envelopeSchema); + } + + private async Task CreateErrorResponseSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + OpenApiSpecVersion openApiVersion, + ApiDescription apiDescription, + OpenApiOperation operation, + PortableOpenApiResponseAttributeBase attribute, + CancellationToken cancellationToken + ) + { + var errorAttribute = (PortableOpenApiErrorResponseAttributeBase) attribute; + ValidateInlineMetadataArrays(errorAttribute); + + var canonicalSchemaId = ResolveCanonicalErrorEnvelopeSchemaId(attribute); + var itemBaseSchemaId = ResolveErrorItemSchemaId(canonicalSchemaId); + var documentedErrorSchema = await CreateDocumentedErrorItemSchemaAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + errorAttribute, + itemBaseSchemaId, + cancellationToken + ); + + if (attribute.TopLevelMetadataType is null && documentedErrorSchema is null) + { + return PortableResultsOpenApiSchemas.CreateSchemaReference(document, canonicalSchemaId); + } + + var derivedEnvelopeSchemaId = PortableResultsOpenApiSchemaNaming.CreateDerivedEnvelopeSchemaId( + canonicalSchemaId, + operation, + apiDescription, + attribute.StatusCode, + attribute.ContentType + ); + + var extensionSchema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + }; + + if (attribute.TopLevelMetadataType is not null) + { + extensionSchema.Properties["metadata"] = await GetStableSchemaReferenceAsync( + document, + context, + attribute.TopLevelMetadataType, + PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(derivedEnvelopeSchemaId), + cancellationToken + ); + } + + if (documentedErrorSchema is not null) + { + var propertyName = canonicalSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId + ? "errorDetails" + : "errors"; + extensionSchema.Properties[propertyName] = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = documentedErrorSchema + }; + } + + var derivedSchema = new OpenApiSchema + { + AllOf = + [ + PortableResultsOpenApiSchemas.CreateSchemaReference(document, canonicalSchemaId), + extensionSchema + ] + }; + return AddComponentAndCreateReference(document, derivedEnvelopeSchemaId, derivedSchema); + } + + private async Task CreateDocumentedErrorItemSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + OpenApiSpecVersion openApiVersion, + ApiDescription apiDescription, + OpenApiOperation operation, + PortableOpenApiErrorResponseAttributeBase attribute, + string itemBaseSchemaId, + CancellationToken cancellationToken + ) + { + var documentedVariants = new List(); + var rawCodeTypes = new Dictionary(StringComparer.Ordinal); + var inlineSanitizedCodes = new Dictionary(StringComparer.Ordinal); + + foreach (var code in attribute.ErrorCodes ?? []) + { + if (!_errorMetadataContractRegistry.Contracts.TryGetValue(code, out var metadataType)) + { + throw new InvalidOperationException(PortableResultsOpenApiMessages.CreateUnknownErrorCodeMessage(code)); + } + + AddDocumentedCode(rawCodeTypes, code, metadataType); + var schemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId(itemBaseSchemaId, code); + documentedVariants.Add( + new DocumentedErrorVariant( + code, + PortableResultsOpenApiSchemas.CreateSchemaReference(document, schemaId) + ) + ); + } + + var inlineCodes = attribute.InlineErrorMetadataCodes; + var inlineTypes = attribute.InlineErrorMetadataTypes; + if (inlineCodes is not null && inlineTypes is not null) + { + for (var i = 0; i < inlineCodes.Length; i++) + { + var code = inlineCodes[i]; + var metadataType = inlineTypes[i]; + var sanitizedCode = PortableResultsOpenApiSchemaNaming.SanitizeErrorCode(code); + if (inlineSanitizedCodes.TryGetValue(sanitizedCode, out var existingCode) && + !string.Equals(existingCode, code, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateSanitizedErrorCodeCollisionMessage( + existingCode, + code, + sanitizedCode + ) + ); + } + + if (rawCodeTypes.TryGetValue(code, out var existingType)) + { + if (existingType != metadataType) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingType, + metadataType + ) + ); + } + + continue; + } + + rawCodeTypes.Add(code, metadataType); + inlineSanitizedCodes.TryAdd(sanitizedCode, code); + var schemaId = PortableResultsOpenApiSchemaNaming.CreateInlineErrorSchemaId( + itemBaseSchemaId, + operation, + apiDescription, + attribute.StatusCode, + attribute.ContentType, + code + ); + var schemaReference = await EnsureCodeSpecificSchemaAsync( + document, + context, + itemBaseSchemaId, + schemaId, + code, + metadataType, + openApiVersion, + cancellationToken + ); + documentedVariants.Add(new DocumentedErrorVariant(code, schemaReference)); + } + } + + if (documentedVariants.Count == 0) + { + return null; + } + + var fallbackSchema = PortableResultsOpenApiSchemas.CreateSchemaReference(document, itemBaseSchemaId); + var anyOfSchemas = new List(documentedVariants.Count + 1); + anyOfSchemas.AddRange(documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference)); + anyOfSchemas.Add(fallbackSchema); + + var discriminatorMapping = documentedVariants.ToDictionary( + static variant => variant.RawCode, + static variant => variant.SchemaReference, + StringComparer.Ordinal + ); + return new OpenApiSchema + { + AnyOf = anyOfSchemas, + Discriminator = new OpenApiDiscriminator + { + PropertyName = "code", + Mapping = discriminatorMapping + } + }; + } + + private async Task EnsureCodeSpecificSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + string baseSchemaId, + string schemaId, + string errorCode, + Type metadataType, + OpenApiSpecVersion openApiVersion, + CancellationToken cancellationToken + ) + { + var schemas = EnsureSchemaStore(document); + if (!schemas.ContainsKey(schemaId)) + { + var metadataSchema = await GetStableSchemaReferenceAsync( + document, + context, + metadataType, + PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(schemaId), + cancellationToken + ); + var schema = CreateCodeSpecificSchema( + document, + baseSchemaId, + errorCode, + metadataSchema, + openApiVersion + ); + schemas.Add(schemaId, schema); + } + + return PortableResultsOpenApiSchemas.CreateSchemaReference(document, schemaId); + } + + private static OpenApiSchema CreateCodeSpecificSchema( + OpenApiDocument document, + string baseSchemaId, + string errorCode, + IOpenApiSchema metadataSchema, + OpenApiSpecVersion openApiVersion + ) + { + var codeSchema = openApiVersion >= OpenApiSpecVersion.OpenApi3_1 + ? new OpenApiSchema + { + Type = JsonSchemaType.String, + Const = errorCode + } + : new OpenApiSchema + { + Type = JsonSchemaType.String, + Enum = [System.Text.Json.Nodes.JsonValue.Create(errorCode)!] + }; + + return new OpenApiSchema + { + AllOf = + [ + PortableResultsOpenApiSchemas.CreateSchemaReference(document, baseSchemaId), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["code"] = codeSchema, + ["metadata"] = metadataSchema + }, + Required = new HashSet(StringComparer.Ordinal) { "code" } + } + ] + }; + } + + private async Task GetStableSchemaReferenceAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + Type metadataType, + string schemaId, + CancellationToken cancellationToken + ) + { + var schema = await context.GetOrCreateSchemaAsync(metadataType, parameterDescription: null, cancellationToken); + return AddComponentAndCreateReference(document, schemaId, schema); + } + + private static OpenApiSchemaReference AddComponentAndCreateReference( + OpenApiDocument document, + string schemaId, + OpenApiSchema schema + ) + { + var schemas = EnsureSchemaStore(document); + if (!schemas.ContainsKey(schemaId)) + { + schemas.Add(schemaId, schema); + } + + return PortableResultsOpenApiSchemas.CreateSchemaReference(document, schemaId); + } + + private static IDictionary EnsureSchemaStore(OpenApiDocument document) + { + document.Components ??= new OpenApiComponents(); + document.Components.Schemas ??= new Dictionary(StringComparer.Ordinal); + return document.Components.Schemas; + } + + private static bool TryGetOperation( + OpenApiDocument document, + ApiDescription apiDescription, + out OpenApiOperation operation + ) + { + operation = default!; + if (document.Paths is null) + { + return false; + } + + var path = NormalizePath(apiDescription.RelativePath); + if (path is null || !document.Paths.TryGetValue(path, out var pathItem)) + { + return false; + } + + var httpMethod = string.IsNullOrWhiteSpace(apiDescription.HttpMethod) + ? null + : new HttpMethod(apiDescription.HttpMethod); + if (httpMethod is null || pathItem.Operations is null || + !pathItem.Operations.TryGetValue(httpMethod, out var resolvedOperation) || + resolvedOperation is null) + { + return false; + } + + operation = resolvedOperation; + return true; + } + + private static OpenApiResponse GetOrCreateResponse(OpenApiOperation operation, int statusCode) + { + operation.Responses ??= new OpenApiResponses(); + var responseKey = statusCode.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!operation.Responses.TryGetValue(responseKey, out var response)) + { + response = new OpenApiResponse + { + Description = $"HTTP {statusCode}" + }; + operation.Responses.Add(responseKey, response); + } + + return (OpenApiResponse) response; + } + + private string ResolveCanonicalErrorEnvelopeSchemaId(PortableOpenApiResponseAttributeBase attribute) + { + if (attribute.Kind == PortableOpenApiResponseKind.Problem) + { + return PortableResultsOpenApiSchemas.PortableProblemDetailsSchemaId; + } + + var validationAttribute = (ProducesPortableValidationProblemAttribute) attribute; + var format = validationAttribute.HasFormatOverride + ? validationAttribute.Format + : _writeOptions.Value.ValidationProblemSerializationFormat; + return format == ValidationProblemSerializationFormat.AspNetCoreCompatible + ? PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId + : PortableResultsOpenApiSchemas.PortableRichValidationProblemDetailsSchemaId; + } + + private static string ResolveErrorItemSchemaId(string canonicalEnvelopeSchemaId) + { + return canonicalEnvelopeSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId + ? PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId + : PortableResultsOpenApiSchemas.PortableErrorSchemaId; + } + + private static void ValidateInlineMetadataArrays(PortableOpenApiErrorResponseAttributeBase attribute) + { + if (attribute.InlineErrorMetadataCodes is null || attribute.InlineErrorMetadataTypes is null) + { + return; + } + + if (attribute.InlineErrorMetadataCodes.Length == attribute.InlineErrorMetadataTypes.Length) + { + return; + } + + throw new InvalidOperationException( + $"Inline error metadata arrays must have the same length, but codes has length {attribute.InlineErrorMetadataCodes.Length} and types has length {attribute.InlineErrorMetadataTypes.Length}." + ); + } + + private static void AddDocumentedCode( + IDictionary rawCodeTypes, + string code, + Type metadataType + ) + { + if (rawCodeTypes.TryGetValue(code, out var existingType)) + { + if (existingType == metadataType) + { + return; + } + + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingType, + metadataType + ) + ); + } + + rawCodeTypes.Add(code, metadataType); + } + + private readonly record struct ResponseGroupKey(int StatusCode, string ContentType); + + private readonly record struct DocumentedErrorVariant( + string RawCode, + OpenApiSchemaReference SchemaReference + ); + + private static string? NormalizePath(string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return null; + } + + var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < segments.Length; i++) + { + segments[i] = NormalizeRouteSegment(segments[i]); + } + + return "/" + string.Join("/", segments); + } + + private static string NormalizeRouteSegment(string segment) + { + if (segment.Length < 2 || segment[0] != '{' || segment[^1] != '}') + { + return segment; + } + + var content = segment[1..^1]; + var constraintSeparatorIndex = content.IndexOf(':'); + if (constraintSeparatorIndex < 0) + { + return segment; + } + + return "{" + content[..constraintSeparatorIndex] + "}"; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs new file mode 100644 index 0000000..f61fdbb --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs @@ -0,0 +1,23 @@ +using System; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +internal static class PortableResultsOpenApiMessages +{ + internal static string CreateDuplicateErrorMetadataContractMessage( + string code, + Type existingType, + Type newType + ) => + $"The error code '{code}' is already registered with metadata type '{existingType.FullName}'. It cannot also be registered with '{newType.FullName}'."; + + internal static string CreateSanitizedErrorCodeCollisionMessage( + string firstCode, + string secondCode, + string sanitizedCode + ) => + $"The error codes '{firstCode}' and '{secondCode}' both sanitize to '{sanitizedCode}'. Error-code schema ids must be unique."; + + internal static string CreateUnknownErrorCodeMessage(string code) => + $"The error code '{code}' is not registered in ConfigureErrorMetadataContracts. Register it globally or use WithErrorMetadata as an inline escape hatch."; +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs new file mode 100644 index 0000000..9b52450 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Service registration helpers for Light.PortableResults OpenAPI support. +/// +public static class PortableResultsOpenApiModule +{ + /// + /// Registers the Light.PortableResults OpenAPI document transformer. + /// + public static IServiceCollection AddPortableResultsOpenApi(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton( + static serviceProvider => + new PortableErrorMetadataContractRegistry( + serviceProvider.GetRequiredService>().Value.Builder + ) + ); + + if (services.Any(static descriptor => descriptor.ServiceType == typeof(PortableResultsOpenApiRegistrationGate))) + { + return services; + } + + services.AddSingleton(); + services.ConfigureAll( + static options => options.AddDocumentTransformer() + ); + return services; + } + + /// + /// Registers global error-code metadata contracts that endpoints can opt into. + /// + public static IServiceCollection ConfigureErrorMetadataContracts( + this IServiceCollection services, + Action configure + ) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + services.Configure(options => configure(options.Builder)); + services.TryAddSingleton( + static serviceProvider => + new PortableErrorMetadataContractRegistry( + serviceProvider.GetRequiredService>().Value.Builder + ) + ); + return services; + } + + private sealed class PortableResultsOpenApiRegistrationGate; +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs new file mode 100644 index 0000000..ae0dc27 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// OpenAPI route-handler helpers for Light.PortableResults. +/// +public static class PortableResultsOpenApiRouteHandlerBuilderExtensions +{ + /// + /// Documents a Light.PortableResults success response. + /// + public static RouteHandlerBuilder ProducesPortableSuccessResponse( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status200OK, + string contentType = "application/json", + Action? configure = null + ) + { + ArgumentNullException.ThrowIfNull(builder); + + var attribute = new ProducesPortableSuccessResponseAttribute(statusCode, contentType); + configure?.Invoke( + new PortableSuccessResponseOpenApiBuilder( + attribute, + mode => attribute.MetadataSerializationMode = mode + ) + ); + return builder.WithMetadata(attribute); + } + + /// + /// Documents a Light.PortableResults problem details response. + /// + public static RouteHandlerBuilder ProducesPortableProblem( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json", + Action? configure = null + ) + { + ArgumentNullException.ThrowIfNull(builder); + + var attribute = new ProducesPortableProblemAttribute(statusCode, contentType); + configure?.Invoke(new PortableProblemOpenApiBuilder(attribute)); + return builder.WithMetadata(attribute); + } + + /// + /// Documents a Light.PortableResults validation problem details response. + /// + public static RouteHandlerBuilder ProducesPortableValidationProblem( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json", + Action? configure = null + ) + { + ArgumentNullException.ThrowIfNull(builder); + + var attribute = new ProducesPortableValidationProblemAttribute(statusCode, contentType); + configure?.Invoke(new PortableValidationProblemOpenApiBuilder(attribute)); + return builder.WithMetadata(attribute); + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs new file mode 100644 index 0000000..3ad182b --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +internal static class PortableResultsOpenApiSchemaNaming +{ + internal static string CreateDerivedEnvelopeSchemaId( + string canonicalName, + OpenApiOperation operation, + ApiDescription apiDescription, + int statusCode, + string contentType + ) + { + var operationToken = CreateOperationToken(operation, apiDescription); + return $"{canonicalName}__{operationToken}__{statusCode}__{SanitizeSegment(contentType)}"; + } + + internal static string CreateInlineErrorSchemaId( + string baseSchemaName, + OpenApiOperation operation, + ApiDescription apiDescription, + int statusCode, + string contentType, + string errorCode + ) + { + var operationToken = CreateOperationToken(operation, apiDescription); + return + $"{baseSchemaName}__{operationToken}__{statusCode}__{SanitizeSegment(contentType)}__{SanitizeErrorCode(errorCode)}"; + } + + internal static string CreateGlobalErrorSchemaId(string baseSchemaName, string errorCode) + { + return $"{baseSchemaName}__{SanitizeErrorCode(errorCode)}"; + } + + internal static string CreateMetadataSchemaId(string ownerSchemaId) + { + return $"{ownerSchemaId}__Metadata"; + } + + internal static string EscapeJsonPointer(string value) + { + ArgumentNullException.ThrowIfNull(value); + return value.Replace("~", "~0", StringComparison.Ordinal) + .Replace("/", "~1", StringComparison.Ordinal); + } + + internal static string SanitizeErrorCode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + return SanitizeSegment(value); + } + + internal static string SanitizeSegment(string value) + { + ArgumentNullException.ThrowIfNull(value); + + Span buffer = stackalloc char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + var character = value[i]; + buffer[i] = char.IsAsciiLetterOrDigit(character) || character == '_' ? character : '_'; + } + + return new string(buffer); + } + + internal static string SanitizeRoutePattern(string routePattern) + { + ArgumentNullException.ThrowIfNull(routePattern); + + Span buffer = stackalloc char[routePattern.Length]; + var outputIndex = 0; + var lastCharacterWasReplacement = false; + foreach (var character in routePattern) + { + var isAllowed = char.IsAsciiLetterOrDigit(character) || character == '_'; + if (isAllowed) + { + buffer[outputIndex++] = character; + lastCharacterWasReplacement = false; + } + else if (!lastCharacterWasReplacement) + { + buffer[outputIndex++] = '_'; + lastCharacterWasReplacement = true; + } + } + + if (outputIndex == 0) + { + return "root"; + } + + var sanitized = new string(buffer[..outputIndex]).Trim('_'); + return string.IsNullOrWhiteSpace(sanitized) ? "root" : sanitized; + } + + private static string CreateOperationToken(OpenApiOperation operation, ApiDescription apiDescription) + { + if (!string.IsNullOrWhiteSpace(operation.OperationId)) + { + return SanitizeSegment(operation.OperationId); + } + + var httpMethod = apiDescription.HttpMethod ?? "Unknown"; + var routePattern = apiDescription.RelativePath ?? string.Empty; + return $"{SanitizeSegment(httpMethod)}__{SanitizeRoutePattern(routePattern)}"; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs new file mode 100644 index 0000000..d14109a --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Installs the canonical Light.PortableResults OpenAPI schemas into a document. +/// +public static class PortableResultsOpenApiSchemas +{ + internal const string PortableErrorSchemaId = "PortableError"; + internal const string PortableValidationErrorDetailSchemaId = "PortableValidationErrorDetail"; + internal const string PortableProblemDetailsSchemaId = "PortableProblemDetails"; + internal const string PortableRichValidationProblemDetailsSchemaId = "PortableRichValidationProblemDetails"; + internal const string PortableAspNetCoreValidationProblemDetailsSchemaId = + "PortableAspNetCoreValidationProblemDetails"; + internal const string ErrorCategorySchemaId = "ErrorCategory"; + + /// + /// Installs the canonical Light.PortableResults schema catalog into the specified document. + /// + public static void InstallInto(OpenApiDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + var schemas = EnsureSchemaStore(document); + AddIfMissing(document, schemas, ErrorCategorySchemaId, CreateErrorCategorySchema()); + AddIfMissing(document, schemas, PortableErrorSchemaId, CreatePortableErrorSchema(document)); + AddIfMissing( + document, + schemas, + PortableValidationErrorDetailSchemaId, + CreatePortableValidationErrorDetailSchema(document) + ); + AddIfMissing(document, schemas, PortableProblemDetailsSchemaId, CreatePortableProblemDetailsSchema(document)); + AddIfMissing( + document, + schemas, + PortableRichValidationProblemDetailsSchemaId, + CreatePortableRichValidationProblemDetailsSchema(document) + ); + AddIfMissing( + document, + schemas, + PortableAspNetCoreValidationProblemDetailsSchemaId, + CreatePortableAspNetCoreValidationProblemDetailsSchema(document) + ); + } + + internal static OpenApiSchema CreateOpenMetadataSchema() + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + AdditionalPropertiesAllowed = true + }; + } + + internal static OpenApiSchemaReference CreateSchemaReference(OpenApiDocument document, string schemaId) + { + return new OpenApiSchemaReference(schemaId, document, externalResource: null); + } + + private static IDictionary EnsureSchemaStore(OpenApiDocument document) + { + document.Components ??= new OpenApiComponents(); + document.Components.Schemas ??= new Dictionary(StringComparer.Ordinal); + return document.Components.Schemas; + } + + private static void AddIfMissing( + OpenApiDocument document, + IDictionary schemas, + string schemaId, + OpenApiSchema schema + ) + { + if (!schemas.ContainsKey(schemaId)) + { + schemas.Add(schemaId, schema); + } + } + + private static OpenApiSchema CreateErrorCategorySchema() + { + return new OpenApiSchema + { + Type = JsonSchemaType.String, + Enum = Enum.GetNames(typeof(ErrorCategory)) + .Select(static name => (JsonNode) JsonValue.Create(name)!) + .ToList() + }; + } + + private static OpenApiSchema CreatePortableErrorSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["message"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["code"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["target"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["category"] = CreateSchemaReference(document, ErrorCategorySchemaId), + ["metadata"] = CreateOpenMetadataSchema() + }, + Required = new HashSet(StringComparer.Ordinal) { "message" } + }; + } + + private static OpenApiSchema CreatePortableValidationErrorDetailSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["target"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["index"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, + ["code"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["category"] = CreateSchemaReference(document, ErrorCategorySchemaId), + ["metadata"] = CreateOpenMetadataSchema() + }, + Required = new HashSet(StringComparer.Ordinal) { "target", "index" } + }; + } + + private static OpenApiSchema CreatePortableProblemDetailsSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = CreateProblemDetailsProperties(document), + Required = new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" } + }; + } + + private static OpenApiSchema CreatePortableRichValidationProblemDetailsSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = CreateProblemDetailsProperties(document), + Required = new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" } + }; + } + + private static OpenApiSchema CreatePortableAspNetCoreValidationProblemDetailsSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["type"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["title"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["status"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, + ["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["errors"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + AdditionalPropertiesAllowed = true, + AdditionalProperties = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["errorDetails"] = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = CreateSchemaReference(document, PortableValidationErrorDetailSchemaId) + }, + ["metadata"] = CreateOpenMetadataSchema() + }, + Required = new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" } + }; + } + + private static Dictionary CreateProblemDetailsProperties(OpenApiDocument document) + { + return new Dictionary(StringComparer.Ordinal) + { + ["type"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["title"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["status"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, + ["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["errors"] = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = CreateSchemaReference(document, PortableErrorSchemaId) + }, + ["metadata"] = CreateOpenMetadataSchema() + }; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs new file mode 100644 index 0000000..455cf86 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs @@ -0,0 +1,50 @@ +using System; +using Light.PortableResults.SharedJsonSerialization; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Configures a documented Light.PortableResults success response. +/// +public sealed class PortableSuccessResponseOpenApiBuilder +{ + private readonly PortableOpenApiResponseAttributeBase _attribute; + private readonly Action _setMetadataSerializationMode; + + internal PortableSuccessResponseOpenApiBuilder( + PortableOpenApiResponseAttributeBase attribute, + Action setMetadataSerializationMode + ) + { + _attribute = attribute; + _setMetadataSerializationMode = setMetadataSerializationMode; + } + + /// + /// Narrows the top-level metadata schema to . + /// + public PortableSuccessResponseOpenApiBuilder WithMetadata() + { + _attribute.TopLevelMetadataType = typeof(TMetadata); + return this; + } + + /// + /// Narrows the top-level metadata schema to the specified CLR type. + /// + public PortableSuccessResponseOpenApiBuilder WithMetadata(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + _attribute.TopLevelMetadataType = metadataType; + return this; + } + + /// + /// Overrides the documented metadata serialization mode for this endpoint. + /// + public PortableSuccessResponseOpenApiBuilder UseMetadataSerializationMode(MetadataSerializationMode mode) + { + _setMetadataSerializationMode(mode); + return this; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs new file mode 100644 index 0000000..364f851 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -0,0 +1,79 @@ +using System; +using Light.PortableResults.Http.Writing; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Configures a documented Light.PortableResults validation problem response. +/// +public sealed class PortableValidationProblemOpenApiBuilder +{ + private readonly ProducesPortableValidationProblemAttribute _attribute; + + internal PortableValidationProblemOpenApiBuilder(ProducesPortableValidationProblemAttribute attribute) => + _attribute = attribute; + + /// + /// Narrows the top-level metadata schema to . + /// + public PortableValidationProblemOpenApiBuilder WithMetadata() + { + _attribute.TopLevelMetadataType = typeof(TMetadata); + return this; + } + + /// + /// Narrows the top-level metadata schema to the specified CLR type. + /// + public PortableValidationProblemOpenApiBuilder WithMetadata(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + _attribute.TopLevelMetadataType = metadataType; + return this; + } + + /// + /// Opts the response into globally registered metadata contracts for the provided error codes. + /// + public PortableValidationProblemOpenApiBuilder WithErrorCodes(params string[] codes) + { + _attribute.ErrorCodes = PortableOpenApiBuilderUtilities.AppendStrings(_attribute.ErrorCodes, codes); + return this; + } + + /// + /// Registers an inline metadata contract for the specified error code. + /// + public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code, Type metadataType) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(metadataType); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataTypes = PortableOpenApiBuilderUtilities.AppendTypes( + _attribute.InlineErrorMetadataTypes, + metadataType + ); + return this; + } + + /// + /// Registers an inline metadata contract for the specified error code. + /// + public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code) + { + return WithErrorMetadata(code, typeof(TMetadata)); + } + + /// + /// Overrides the documented validation problem serialization format for this endpoint. + /// + public PortableValidationProblemOpenApiBuilder UseFormat(ValidationProblemSerializationFormat format) + { + _attribute.Format = format; + return this; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs new file mode 100644 index 0000000..5577d28 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Documents a Light.PortableResults problem details response. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableProblemAttribute : PortableOpenApiErrorResponseAttributeBase +{ + /// + /// Initializes a new instance of . + /// + /// The documented HTTP status code. + /// The documented content type. + public ProducesPortableProblemAttribute( + int statusCode = StatusCodes.Status500InternalServerError, + string contentType = "application/problem+json" + ) : base(PortableOpenApiResponseKind.Problem, statusCode, contentType) { } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs new file mode 100644 index 0000000..5bfd032 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs @@ -0,0 +1,52 @@ +using System; +using Light.PortableResults.SharedJsonSerialization; +using Microsoft.AspNetCore.Http; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Documents a Light.PortableResults success response. +/// +/// The response value type. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableSuccessResponseAttribute : PortableOpenApiResponseAttributeBase + , IPortableSuccessResponseOpenApiAttribute +{ + private MetadataSerializationMode _metadataSerializationMode; + + /// + /// Initializes a new instance of . + /// + /// The documented HTTP status code. + /// The documented content type. + public ProducesPortableSuccessResponseAttribute( + int statusCode = StatusCodes.Status200OK, + string contentType = "application/json" + ) : base(PortableOpenApiResponseKind.SuccessResponse, statusCode, contentType) + { + ValueType = typeof(TValue); + } + + /// + /// Gets the response value type. + /// + public Type ValueType { get; } + + /// + /// Gets or sets the optional documentation-only override for the metadata serialization mode. + /// + public MetadataSerializationMode MetadataSerializationMode + { + get => _metadataSerializationMode; + set + { + _metadataSerializationMode = value; + HasMetadataSerializationModeOverride = true; + } + } + + bool IPortableSuccessResponseOpenApiAttribute.HasMetadataSerializationModeOverride => + HasMetadataSerializationModeOverride; + + private bool HasMetadataSerializationModeOverride { get; set; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs new file mode 100644 index 0000000..6e72475 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs @@ -0,0 +1,39 @@ +using System; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.Http; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Documents a Light.PortableResults validation problem response. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableValidationProblemAttribute : PortableOpenApiErrorResponseAttributeBase +{ + private ValidationProblemSerializationFormat _format; + + /// + /// Initializes a new instance of . + /// + /// The documented HTTP status code. + /// The documented content type. + public ProducesPortableValidationProblemAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) : base(PortableOpenApiResponseKind.ValidationProblem, statusCode, contentType) { } + + /// + /// Gets or sets the optional documentation-only override for the validation serialization format. + /// + public ValidationProblemSerializationFormat Format + { + get => _format; + set + { + _format = value; + HasFormatOverride = true; + } + } + + internal bool HasFormatOverride { get; private set; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json new file mode 100644 index 0000000..483fd3e --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json @@ -0,0 +1,87 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==" + }, + "light.portableresults": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.HashCode": "[6.0.0, )", + "Ulid": "[1.4.1, )" + } + }, + "light.portableresults.aspnetcore.shared": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "CentralTransitive", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Ulid": { + "type": "CentralTransitive", + "requested": "[1.4.1, )", + "resolved": "1.4.1", + "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" + } + } + } +} \ No newline at end of file diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs deleted file mode 100644 index 79b2985..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableAspNetCoreValidationProblemDetails.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Http; - -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// RFC 9457 problem details response returned for validation failures in the ASP.NET -/// Core-compatible format. The inherited errors property is a -/// Dictionary<string, string[]> (field name to messages) for compatibility with -/// clients that expect the ASP.NET Core default; the optional errorDetails array -/// supplies Light.PortableResults-specific information such as codes, categories, and -/// metadata for each message. -/// -/// The shape of the per-detail metadata on each errorDetails entry. -/// The shape of the top-level metadata bag. -/// -/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat -/// is set to AspNetCoreCompatible. This is a schema-only type used by -/// Light.PortableResults for OpenAPI documentation; the wire format is produced directly by -/// the runtime HTTP writers. -/// -public class PortableAspNetCoreValidationProblemDetails - : HttpValidationProblemDetails -{ - /// - /// Optional Light.PortableResults-specific details that correlate with the inherited - /// errors dictionary. Each entry points back to a message in - /// errors[target] via its index property. - /// - public IReadOnlyList>? ErrorDetails { get; init; } - - /// - /// Optional structured information about the validation failure as a whole, separate from - /// any individual error item. - /// - public TProblemMetadata Metadata { get; init; } = default!; -} - -/// -/// RFC 9457 problem details response returned for validation failures when the API is -/// configured to serialize validation errors in the ASP.NET Core-compatible format. The -/// inherited errors property is a Dictionary<string, string[]> for -/// compatibility with clients that expect the ASP.NET Core default; the optional -/// errorDetails array supplies Light.PortableResults-specific information. -/// -/// -/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat -/// is set to AspNetCoreCompatible. This is a schema-only type used by -/// Light.PortableResults for OpenAPI documentation; the wire format is produced directly by -/// the runtime HTTP writers. -/// -public class PortableAspNetCoreValidationProblemDetails - : PortableAspNetCoreValidationProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs deleted file mode 100644 index 0bcc2ca..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableError.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// A single error entry describing why a request failed. Each error carries a human-readable -/// message, a stable machine-readable code, an optional target field, a category, and an -/// optional free-form metadata bag. -/// -/// -/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format -/// is produced directly by the runtime HTTP writers. -/// -public class PortableError -{ - /// - /// Human-readable description of what went wrong. - /// - public string Message { get; init; } = string.Empty; - - /// - /// Stable machine-readable identifier of this kind of error. Intended for callers that - /// branch on error types programmatically. - /// - public string? Code { get; init; } - - /// - /// The input field, property, or resource that the error refers to, if applicable. - /// - public string? Target { get; init; } - - /// - /// Classification of the error (validation, conflict, authentication, and so on). Maps to - /// the HTTP status code that the API surfaces for the overall response. - /// - public ErrorCategory Category { get; init; } - - /// - /// Additional structured information about the error, for example the lower and upper - /// boundary of a failing range check. The shape is error-specific. - /// - public object? Metadata { get; init; } -} - -/// -/// A single error entry describing why a request failed. Each error carries a human-readable -/// message, a stable machine-readable code, an optional target field, a category, and an -/// optional structured metadata bag. -/// -/// The shape of the per-error metadata. See for the non-generic variant. -/// -/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format -/// is produced directly by the runtime HTTP writers. -/// -public class PortableError -{ - /// - /// Human-readable description of what went wrong. - /// - public string Message { get; init; } = string.Empty; - - /// - /// Stable machine-readable identifier of this kind of error. Intended for callers that - /// branch on error types programmatically. - /// - public string? Code { get; init; } - - /// - /// The input field, property, or resource that the error refers to, if applicable. - /// - public string? Target { get; init; } - - /// - /// Classification of the error (validation, conflict, authentication, and so on). Maps to - /// the HTTP status code that the API surfaces for the overall response. - /// - public ErrorCategory Category { get; init; } - - /// - /// Additional structured information about the error, for example the lower and upper - /// boundary of a failing range check. - /// - public TMetadata Metadata { get; init; } = default!; -} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs deleted file mode 100644 index 8a26e0c..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableProblemDetails.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// RFC 9457 problem details response returned for a non-validation failure (for example 401, -/// 403, 404, 409, or 500). Carries the standard problem details fields plus a list of -/// Light.PortableResults error items and optional top-level problem metadata. -/// -/// The shape of the per-error metadata on each errors entry. -/// The shape of the top-level metadata bag. -/// -/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format -/// is produced directly by the runtime HTTP writers. -/// -public class PortableProblemDetails : ProblemDetails -{ - /// - /// The error items that describe why the request failed. Typically contains a single entry - /// for non-validation failures. - /// - public IReadOnlyList> Errors { get; init; } = - new List>(); - - /// - /// Optional structured information about the failure as a whole, separate from any - /// individual error item. - /// - public TProblemMetadata Metadata { get; init; } = default!; -} - -/// -/// RFC 9457 problem details response returned for a non-validation failure (for example 401, -/// 403, 404, 409, or 500). Carries the standard problem details fields plus a list of -/// Light.PortableResults error items and optional top-level problem metadata. -/// -/// -/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format -/// is produced directly by the runtime HTTP writers. -/// -public class PortableProblemDetails : PortableProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs deleted file mode 100644 index df226db..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableResultsOpenApiNamingConventions.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Text.Json.Serialization.Metadata; - -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// Naming conventions for the Light.PortableResults schema-only types so that -/// Microsoft.AspNetCore.OpenApi emits readable schema names such as -/// PortableError or PortableProblemDetails instead of the default -/// PortableErrorOfObject or PortableProblemDetailsOfObjectAndObject. -/// -/// -/// Register the convention when configuring OpenAPI, composing it with the default naming: -/// -/// builder.Services.AddOpenApi(options => -/// { -/// options.CreateSchemaReferenceId = type => -/// PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(type) ?? -/// OpenApiOptions.CreateDefaultSchemaReferenceId(type); -/// }); -/// -/// The helper only produces custom names for Light.PortableResults schema-only types whose -/// generic arguments are all . Any other type returns -/// and is expected to be handled by the caller's fallback. -/// -public static class PortableResultsOpenApiNamingConventions -{ - private const string SchemaNamespace = "Light.PortableResults.AspNetCore.Shared"; - - /// - /// Attempts to compute an OpenAPI schema reference id for a Light.PortableResults schema-only - /// type whose generic arguments are all . For - /// PortableError<object> this returns "PortableError", for - /// PortableProblemDetails<object, object> this returns - /// "PortableProblemDetails", and so on. - /// - /// The JSON type info for which the OpenAPI schema reference id is built. - /// - /// A custom schema reference id for recognized Light.PortableResults schema-only types, - /// or when the default naming should be used. - /// - /// Thrown when is null. - public static string? TryCreateSchemaReferenceId(JsonTypeInfo typeInfo) - { - if (typeInfo is null) - { - throw new ArgumentNullException(nameof(typeInfo)); - } - - return TryGetSimpleNameForAllObjectGenericArgs(typeInfo.Type); - } - - private static string? TryGetSimpleNameForAllObjectGenericArgs(Type type) - { - if (type.Namespace != SchemaNamespace) - { - return null; - } - - if (!type.IsGenericType || type.IsGenericTypeDefinition) - { - return null; - } - - var genericArguments = type.GetGenericArguments(); - for (var i = 0; i < genericArguments.Length; i++) - { - if (genericArguments[i] != typeof(object)) - { - return null; - } - } - - var name = type.Name; - var tickIndex = name.IndexOf('`'); - return tickIndex < 0 ? name : name.Substring(0, tickIndex); - } -} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs deleted file mode 100644 index 7fb18ca..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableRichValidationProblemDetails.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Generic; -using Microsoft.AspNetCore.Mvc; - -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// RFC 9457 problem details response returned for validation failures when the API is -/// configured to serialize validation errors in the rich Light.PortableResults format. The -/// errors property is a structured array of error items rather than the ASP.NET Core -/// default Dictionary<string, string[]>. -/// -/// The shape of the per-error metadata on each errors entry. -/// The shape of the top-level metadata bag. -/// -/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat -/// is set to Rich. This is a schema-only type used by Light.PortableResults for OpenAPI -/// documentation; the wire format is produced directly by the runtime HTTP writers. -/// -public class PortableRichValidationProblemDetails : ProblemDetails -{ - /// - /// The validation errors that caused the request to be rejected. - /// - public IReadOnlyList> Errors { get; init; } = - new List>(); - - /// - /// Optional structured information about the validation failure as a whole, separate from - /// any individual error item. - /// - public TProblemMetadata Metadata { get; init; } = default!; -} - -/// -/// RFC 9457 problem details response returned for validation failures when the API is -/// configured to serialize validation errors in the rich Light.PortableResults format. The -/// errors property is a structured array of error items rather than the ASP.NET Core -/// default Dictionary<string, string[]>. -/// -/// -/// Use this schema when PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat -/// is set to Rich. This is a schema-only type used by Light.PortableResults for OpenAPI -/// documentation; the wire format is produced directly by the runtime HTTP writers. -/// -public class PortableRichValidationProblemDetails : PortableRichValidationProblemDetails; diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs deleted file mode 100644 index 1b8475d..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableSuccessResponse.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// Successful response body that wraps a primary together with -/// a bag of , for endpoints that opt into returning metadata -/// alongside the value. -/// -/// The shape of the main payload. -/// The shape of the metadata accompanying the payload. -/// -/// Use this schema only when the runtime is configured to emit the { value, metadata } -/// envelope (see MetadataSerializationMode.Always). For plain payload responses, use the -/// standard ASP.NET Core OpenAPI helpers such as Produces<TValue> or -/// ProducesResponseTypeAttribute<TValue> instead. This is a schema-only type used -/// by Light.PortableResults for OpenAPI documentation; the wire format is produced directly by -/// the runtime HTTP writers. -/// -public class PortableSuccessResponse -{ - /// - /// The primary payload returned by the operation. - /// - public TValue Value { get; init; } = default!; - - /// - /// Additional structured information associated with the payload, for example paging - /// details or aggregate counts. - /// - public TMetadata Metadata { get; init; } = default!; -} diff --git a/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs b/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs deleted file mode 100644 index 2fa2c3a..0000000 --- a/src/Light.PortableResults.AspNetCore.Shared/PortableValidationErrorDetail.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace Light.PortableResults.AspNetCore.Shared; - -/// -/// A Light.PortableResults-specific supplement for a single validation error inside an -/// ASP.NET Core-compatible problem details response. Correlates to a message in the standard -/// errors dictionary and adds machine-readable information such as an error code, -/// category, and metadata bag. -/// -/// -/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format -/// is produced directly by the runtime HTTP writers. -/// -public class PortableValidationErrorDetail -{ - /// - /// The input field, property, or resource this error detail refers to. Matches the key - /// in the inherited errors dictionary of the enclosing problem details response. - /// - public string Target { get; init; } = string.Empty; - - /// - /// Zero-based position of the corresponding error message within errors[target] - /// for the same target, so errorDetails entries can be correlated back to the - /// original message. - /// - public int Index { get; init; } - - /// - /// Stable machine-readable identifier of this kind of error. Intended for callers that - /// branch on error types programmatically. - /// - public string? Code { get; init; } - - /// - /// Optional classification of the error (validation, conflict, authentication, and so on). - /// - public ErrorCategory? Category { get; init; } - - /// - /// Additional structured information about the error, for example the lower and upper - /// boundary of a failing range check. - /// - public object? Metadata { get; init; } -} - -/// -/// A Light.PortableResults-specific supplement for a single validation error inside an -/// ASP.NET Core-compatible problem details response. Correlates to a message in the standard -/// errors dictionary and adds machine-readable information such as an error code, -/// category, and metadata bag. -/// -/// The shape of the per-error-detail metadata. See for the non-generic variant. -/// -/// Schema-only type used by Light.PortableResults for OpenAPI documentation; the wire format -/// is produced directly by the runtime HTTP writers. -/// -public class PortableValidationErrorDetail -{ - /// - /// The input field, property, or resource this error detail refers to. Matches the key - /// in the inherited errors dictionary of the enclosing problem details response. - /// - public string Target { get; init; } = string.Empty; - - /// - /// Zero-based position of the corresponding error message within errors[target] - /// for the same target, so errorDetails entries can be correlated back to the - /// original message. - /// - public int Index { get; init; } - - /// - /// Stable machine-readable identifier of this kind of error. Intended for callers that - /// branch on error types programmatically. - /// - public string? Code { get; init; } - - /// - /// Optional classification of the error (validation, conflict, authentication, and so on). - /// - public ErrorCategory? Category { get; init; } - - /// - /// Additional structured information about the error, for example the lower and upper - /// boundary of a failing range check. - /// - public TMetadata Metadata { get; init; } = default!; -} diff --git a/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json index 3bf4acc..05ff998 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net10.0": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" + }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", "requested": "[10.0.3, )", diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index 20e9256..9d075cc 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -1,6 +1,7 @@ - + netstandard2.0 + false Framework-agnostic validation foundations for Light.PortableResults, including validation contexts, low-allocation checks, validator base classes, and validated value pipelines. Light.PortableResults.Validation 0.4.0 diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index 09a609b..01fb2ea 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -1,6 +1,7 @@ - + netstandard2.0 + false The Light.PortableResults package implements the core functionality: Results, Errors, Metadata, Functional Extensions, and serialization support for various formats like HTTP and CloudEvents. Compatible with Native AOT. Check out the integration packages Light.PortableResults.AspNetCore.MinimalApis or Light.PortableResults.AspNetCore.Mvc. Light.PortableResults 0.4.0 diff --git a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs deleted file mode 100644 index 328b545..0000000 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using Light.PortableResults.AspNetCore.Shared; -using Light.PortableResults.Metadata; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Xunit; - -namespace Light.PortableResults.AspNetCore.MinimalApis.Tests; - -public sealed class PortableResultsEndpointExtensionsTests -{ - public static TheoryData, Type, int, string> RegistrationCases { get; } = new () - { - { - builder => builder.ProducesPortableSuccessResponse(), - typeof(PortableSuccessResponse), - StatusCodes.Status200OK, - "application/json" - }, - { - builder => builder.ProducesPortableSuccessResponse>( - statusCode: 201 - ), - typeof(PortableSuccessResponse>), - StatusCodes.Status201Created, - "application/json" - }, - { - builder => builder.ProducesPortableProblem(), - typeof(PortableProblemDetails), - StatusCodes.Status500InternalServerError, - "application/problem+json" - }, - { - builder => builder.ProducesPortableProblem(statusCode: StatusCodes.Status404NotFound), - typeof(PortableProblemDetails), - StatusCodes.Status404NotFound, - "application/problem+json" - }, - { - builder => builder.ProducesPortableProblem( - statusCode: StatusCodes.Status409Conflict - ), - typeof(PortableProblemDetails), - StatusCodes.Status409Conflict, - "application/problem+json" - }, - { - builder => builder.ProducesPortableRichValidationProblem(), - typeof(PortableRichValidationProblemDetails), - StatusCodes.Status400BadRequest, - "application/problem+json" - }, - { - builder => builder.ProducesPortableRichValidationProblem( - statusCode: StatusCodes.Status422UnprocessableEntity - ), - typeof(PortableRichValidationProblemDetails), - StatusCodes.Status422UnprocessableEntity, - "application/problem+json" - }, - { - builder => builder.ProducesPortableAspNetCoreValidationProblem(), - typeof(PortableAspNetCoreValidationProblemDetails), - StatusCodes.Status400BadRequest, - "application/problem+json" - }, - { - builder => builder.ProducesPortableAspNetCoreValidationProblem( - statusCode: StatusCodes.Status422UnprocessableEntity - ), - typeof(PortableAspNetCoreValidationProblemDetails), - StatusCodes.Status422UnprocessableEntity, - "application/problem+json" - } - }; - - [Theory] - [MemberData(nameof(RegistrationCases))] - public void HelperShouldRegisterExpectedMetadata( - Action register, - Type expectedType, - int expectedStatusCode, - string expectedContentType - ) - { - var builder = WebApplication.CreateBuilder(); - var app = builder.Build(); - var routeBuilder = app.MapGet("/test", () => "ok"); - - register(routeBuilder); - - var endpointRouteBuilder = (IEndpointRouteBuilder) app; - var endpoint = endpointRouteBuilder.DataSources.Single().Endpoints.OfType().Single(); - var matches = endpoint.Metadata - .Where(item => item.GetType().Name == "ProducesResponseTypeMetadata") - .Select( - entry => new - { - Type = (Type?) entry.GetType().GetProperty("Type")?.GetValue(entry), - StatusCode = (int?) entry.GetType().GetProperty("StatusCode")?.GetValue(entry), - ContentTypes = (IEnumerable?) entry - .GetType() - .GetProperty("ContentTypes") - ?.GetValue(entry) - } - ) - .ToArray(); - - matches - .Should() - .ContainSingle( - entry => entry.Type == expectedType && - entry.StatusCode == expectedStatusCode && - entry.ContentTypes != null && - entry.ContentTypes.Contains(expectedContentType) - ); - } -} diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs index fc45c07..4c25cc2 100644 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs @@ -10,7 +10,7 @@ namespace Light.PortableResults.AspNetCore.Mvc.Tests.IntegrationTests; public sealed class RegularMvcController : ControllerBase { [HttpGet] - [ProducesPortableSuccessResponse, Dictionary>] + [ProducesResponseType>(200)] public LightActionResult> GetContacts() { var contact1 = new ContactDto { Id = new Guid("D8FC9BEC-0606-4E9B-8EB4-04558B2B9D40"), Name = "Foo" }; diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs deleted file mode 100644 index d91b3d6..0000000 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/UnitTests/ProducesPortableAttributesTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; -using FluentAssertions; -using Light.PortableResults.AspNetCore.Shared; -using Light.PortableResults.Metadata; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.AspNetCore.Mvc.Formatters; -using Xunit; - -namespace Light.PortableResults.AspNetCore.Mvc.Tests.UnitTests; - -public sealed class ProducesPortableAttributesTests -{ - public static TheoryData AttributeCases { get; } = - new () - { - { - new ProducesPortableSuccessResponseAttribute(), - typeof(PortableSuccessResponse), - StatusCodes.Status200OK, - "application/json" - }, - { - new ProducesPortableSuccessResponseAttribute>( - statusCode: StatusCodes.Status201Created - ), - typeof(PortableSuccessResponse>), - StatusCodes.Status201Created, - "application/json" - }, - { - new ProducesPortableProblemAttribute(), - typeof(PortableProblemDetails), - StatusCodes.Status500InternalServerError, - "application/problem+json" - }, - { - new ProducesPortableProblemAttribute(statusCode: StatusCodes.Status404NotFound), - typeof(PortableProblemDetails), - StatusCodes.Status404NotFound, - "application/problem+json" - }, - { - new ProducesPortableProblemAttribute( - statusCode: StatusCodes.Status409Conflict - ), - typeof(PortableProblemDetails), - StatusCodes.Status409Conflict, - "application/problem+json" - }, - { - new ProducesPortableRichValidationProblemAttribute(), - typeof(PortableRichValidationProblemDetails), - StatusCodes.Status400BadRequest, - "application/problem+json" - }, - { - new ProducesPortableRichValidationProblemAttribute( - statusCode: StatusCodes.Status422UnprocessableEntity - ), - typeof(PortableRichValidationProblemDetails), - StatusCodes.Status422UnprocessableEntity, - "application/problem+json" - }, - { - new ProducesPortableAspNetCoreValidationProblemAttribute(), - typeof(PortableAspNetCoreValidationProblemDetails), - StatusCodes.Status400BadRequest, - "application/problem+json" - }, - { - new ProducesPortableAspNetCoreValidationProblemAttribute( - statusCode: StatusCodes.Status422UnprocessableEntity - ), - typeof(PortableAspNetCoreValidationProblemDetails), - StatusCodes.Status422UnprocessableEntity, - "application/problem+json" - } - }; - - [Theory] - [MemberData(nameof(AttributeCases))] - public void AttributeExposesExpectedMetadata( - ProducesResponseTypeAttribute attribute, - Type expectedType, - int expectedStatusCode, - string expectedContentType - ) - { - var contentTypes = new MediaTypeCollection(); - ((IApiResponseMetadataProvider) attribute).SetContentTypes(contentTypes); - - attribute.Type.Should().Be(expectedType); - attribute.StatusCode.Should().Be(expectedStatusCode); - contentTypes.Should().Contain(expectedContentType); - } -} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj new file mode 100644 index 0000000..dc62581 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj @@ -0,0 +1,32 @@ + + + + Exe + false + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs new file mode 100644 index 0000000..59306f6 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.Mvc; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.SharedJsonSerialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class PortableResultsOpenApiDocumentTransformerTests +{ + [Fact] + public async Task MinimalApiDocument_ShouldEmitConfiguredSchemas() + { + await using var app = CreateMinimalApiApp(); + + var document = await GetOpenApiDocumentAsync(app); + + var defaultSuccessSchema = GetResponseSchema( + document, + "/minimal/success/default", + HttpMethod.Get, + StatusCodes.Status200OK, + "application/json" + ); + defaultSuccessSchema.Should().BeOfType(); + ((OpenApiSchema) defaultSuccessSchema).Properties.Should().ContainKey("title"); + + var wrappedSuccessSchema = GetResponseSchema( + document, + "/minimal/success/wrapped", + HttpMethod.Get, + StatusCodes.Status200OK, + "application/json" + ).Should().BeOfType().Subject; + var wrappedSuccessComponent = GetSchemaComponent(document, GetSchemaReferenceId(wrappedSuccessSchema)); + wrappedSuccessComponent.Properties.Should().ContainKeys("value", "metadata"); + wrappedSuccessComponent.Required.Should().Contain("value"); + wrappedSuccessComponent.Properties["metadata"].Should().BeOfType(); + var successMetadataReference = (OpenApiSchemaReference) wrappedSuccessComponent.Properties["metadata"]; + GetSchemaComponent(document, GetSchemaReferenceId(successMetadataReference)) + .Properties.Should().ContainKey("traceId"); + + var globalProblemSchema = GetResponseSchema( + document, + "/minimal/problems/global", + HttpMethod.Get, + StatusCodes.Status409Conflict, + "application/problem+json" + ).Should().BeOfType().Subject; + var globalProblemComponent = GetSchemaComponent(document, GetSchemaReferenceId(globalProblemSchema)); + var globalProblemExtension = (OpenApiSchema) globalProblemComponent.AllOf![1]; + var globalProblemErrors = (OpenApiSchema) globalProblemExtension.Properties!["errors"]!; + var globalProblemItems = (OpenApiSchema) globalProblemErrors.Items!; + globalProblemItems.AnyOf.Should().HaveCount(3); + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf![0]).Should().Be("PortableError__VersionMismatch"); + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[1]).Should().Be("PortableError__Insufficient_Funds"); + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[2]).Should().Be("PortableError"); + globalProblemItems.Discriminator.Should().NotBeNull(); + globalProblemItems.Discriminator!.PropertyName.Should().Be("code"); + globalProblemItems.Discriminator.Mapping.Should().NotBeNull(); + globalProblemItems.Discriminator.Mapping!.Keys.Should().BeEquivalentTo("VersionMismatch", "Insufficient/Funds"); + GetSchemaReferenceId(globalProblemItems.Discriminator.Mapping["Insufficient/Funds"]) + .Should().Be("PortableError__Insufficient_Funds"); + + var inlineProblemSchema = GetResponseSchema( + document, + "/minimal/problems/inline", + HttpMethod.Get, + StatusCodes.Status404NotFound, + "application/problem+json" + ).Should().BeOfType().Subject; + var inlineProblemComponent = GetSchemaComponent(document, GetSchemaReferenceId(inlineProblemSchema)); + var inlineProblemExtension = (OpenApiSchema) inlineProblemComponent.AllOf![1]; + var inlineProblemItems = (OpenApiSchema) ((OpenApiSchema) inlineProblemExtension.Properties!["errors"]!).Items!; + GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf![0]).Should().Contain("PortableError__"); + GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf[0]).Should().Contain("Movie_Gone"); + GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf[1]).Should().Be("PortableError"); + + var defaultValidationSchema = GetResponseSchema( + document, + "/minimal/validation/default", + HttpMethod.Get, + StatusCodes.Status400BadRequest, + "application/problem+json" + ).Should().BeOfType().Subject; + GetSchemaReferenceId(defaultValidationSchema).Should().Be("PortableAspNetCoreValidationProblemDetails"); + + var richValidationSchema = GetResponseSchema( + document, + "/minimal/validation/rich", + HttpMethod.Get, + StatusCodes.Status400BadRequest, + "application/problem+json" + ).Should().BeOfType().Subject; + GetSchemaReferenceId(richValidationSchema).Should().StartWith("PortableRichValidationProblemDetails__"); + + var unionSchema = GetResponseSchema( + document, + "/minimal/problems/union", + HttpMethod.Get, + StatusCodes.Status400BadRequest, + "application/problem+json" + ).Should().BeOfType().Subject; + unionSchema.AnyOf.Should().HaveCount(2); + } + + [Fact] + public async Task MvcDocument_ShouldHonorPortableOpenApiAttributes() + { + await using var app = CreateMvcApp(); + + var document = await GetOpenApiDocumentAsync(app); + + var successSchema = GetResponseSchema( + document, + "/mvc/openapi/success", + HttpMethod.Get, + StatusCodes.Status200OK, + "application/json" + ).Should().BeOfType().Subject; + GetSchemaComponent(document, GetSchemaReferenceId(successSchema)).Properties.Should().ContainKeys("value", "metadata"); + + var problemSchema = GetResponseSchema( + document, + "/mvc/openapi/problem", + HttpMethod.Get, + StatusCodes.Status404NotFound, + "application/problem+json" + ).Should().BeOfType().Subject; + var problemComponent = GetSchemaComponent(document, GetSchemaReferenceId(problemSchema)); + ((OpenApiSchema) problemComponent.AllOf![1]).Properties.Should().ContainKey("errors"); + + var validationSchema = GetResponseSchema( + document, + "/mvc/openapi/validation", + HttpMethod.Get, + StatusCodes.Status400BadRequest, + "application/problem+json" + ).Should().BeOfType().Subject; + GetSchemaReferenceId(validationSchema).Should().StartWith("PortableRichValidationProblemDetails__"); + } + + [Fact] + public async Task Transformer_ShouldUseEnumNarrowingForOpenApi30AndConstForOpenApi31() + { + await using var openApi30App = CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0); + var openApi30Document = await GetOpenApiDocumentAsync(openApi30App); + + var openApi30Variant = GetSchemaComponent(openApi30Document, "PortableError__VersionMismatch"); + var openApi30CodeSchema = (OpenApiSchema) ((OpenApiSchema) openApi30Variant.AllOf![1]).Properties!["code"]!; + openApi30CodeSchema.Const.Should().BeNull(); + openApi30CodeSchema.Enum.Should().ContainSingle(); + openApi30CodeSchema.Enum![0]!.ToJsonString().Should().Be("\"VersionMismatch\""); + + await using var openApi31App = CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1); + var openApi31Document = await GetOpenApiDocumentAsync(openApi31App); + + var openApi31Variant = GetSchemaComponent(openApi31Document, "PortableError__VersionMismatch"); + var openApi31CodeSchema = (OpenApiSchema) ((OpenApiSchema) openApi31Variant.AllOf![1]).Properties!["code"]!; + openApi31CodeSchema.Const.Should().Be("VersionMismatch"); + } + + [Fact] + public async Task Transformer_ShouldThrowWhenAnEndpointUsesAnUnknownGlobalErrorCode() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/unknown", static () => TypedResults.Problem()) + .ProducesPortableProblem(configure: x => x.WithErrorCodes("UnknownCode")); + } + ); + + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .WithMessage("*UnknownCode*ConfigureErrorMetadataContracts*WithErrorMetadata*"); + } + + [Fact] + public async Task Transformer_ShouldThrowWhenSuccessMetadataIsDocumentedForErrorsOnlyMode() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/success/invalid", static () => TypedResults.Ok(new MovieDto())) + .ProducesPortableSuccessResponse(configure: x => x.WithMetadata()); + } + ); + + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .WithMessage("*MetadataSerializationMode is ErrorsOnly*"); + } + + [Fact] + public async Task Transformer_ShouldThrowWhenDuplicateKindsShareTheSameResponseKey() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/ambiguous", static () => TypedResults.Problem()) + .ProducesPortableProblem(StatusCodes.Status400BadRequest) + .ProducesPortableProblem(StatusCodes.Status400BadRequest); + } + ); + + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .WithMessage("*status code 400*kind 'Problem'*"); + } + + [Fact] + public void ErrorMetadataContractsBuilder_ShouldRejectSanitizedCodeCollisions() + { + var builder = new PortableErrorMetadataContractsBuilder(); + + builder.ForCode("Code/One"); + var act = () => builder.ForCode("Code_One"); + + act.Should().Throw().WithMessage("*Code/One*Code_One*"); + } + + private static async Task GetOpenApiDocumentAsync(WebApplication app) + { + await app.StartAsync(TestContext.Current.CancellationToken); + var provider = app.Services.GetRequiredKeyedService("v1"); + return await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken); + } + + private static WebApplication CreateMinimalApiApp( + Action? configureOpenApi = null, + Action? configureEndpoints = null + ) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMinimalApis(); + builder.Services.AddPortableResultsOpenApi(); + builder.Services.Configure( + options => + { + options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; + options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.AspNetCoreCompatible; + } + ); + builder.Services.ConfigureErrorMetadataContracts( + contracts => + { + contracts.ForCode("VersionMismatch"); + contracts.ForCode("Insufficient/Funds"); + } + ); + builder.Services.AddOpenApi(options => configureOpenApi?.Invoke(options)); + + var app = builder.Build(); + app.MapGet("/minimal/success/default", static () => TypedResults.Ok(new MovieDto())) + .ProducesPortableSuccessResponse(); + app.MapGet("/minimal/success/wrapped", static () => TypedResults.Ok(new MovieDto())) + .ProducesPortableSuccessResponse( + configure: x => + x.WithMetadata() + .UseMetadataSerializationMode(MetadataSerializationMode.Always) + ); + app.MapGet("/minimal/problems/global", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status409Conflict, + configure: x => + x.WithMetadata() + .WithErrorCodes("VersionMismatch", "Insufficient/Funds") + ); + app.MapGet("/minimal/problems/inline", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status404NotFound, + configure: x => + x.WithMetadata() + .WithErrorMetadata("Movie/Gone") + ); + app.MapGet("/minimal/validation/default", static () => TypedResults.Problem()) + .ProducesPortableValidationProblem(); + app.MapGet("/minimal/validation/rich", static () => TypedResults.Problem()) + .ProducesPortableValidationProblem( + configure: x => + x.UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes("VersionMismatch") + ); + app.MapGet("/minimal/problems/union", static () => TypedResults.Problem()) + .ProducesPortableProblem(StatusCodes.Status400BadRequest) + .ProducesPortableValidationProblem(StatusCodes.Status400BadRequest); + + configureEndpoints?.Invoke(app); + return app; + } + + private static WebApplication CreateMvcApp() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMvc(); + builder.Services.AddPortableResultsOpenApi(); + builder.Services.Configure( + options => + { + options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; + options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.AspNetCoreCompatible; + } + ); + builder.Services.ConfigureErrorMetadataContracts( + contracts => contracts.ForCode("VersionMismatch") + ); + builder.Services.AddControllers().AddApplicationPart(typeof(OpenApiMvcController).Assembly); + builder.Services.AddOpenApi(); + + var app = builder.Build(); + app.MapControllers(); + return app; + } + + private static IOpenApiSchema GetResponseSchema( + OpenApiDocument document, + string path, + HttpMethod httpMethod, + int statusCode, + string contentType + ) + { + var pathItem = document.Paths![path]; + var operation = pathItem.Operations![httpMethod]; + var response = (OpenApiResponse) operation.Responses![statusCode.ToString(CultureInfo.InvariantCulture)]; + return response.Content![contentType].Schema!; + } + + private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) + { + return (OpenApiSchema) document.Components!.Schemas![schemaId]; + } + + private static string GetSchemaReferenceId(OpenApiSchemaReference schemaReference) + { + var referenceId = schemaReference.Reference?.Id ?? schemaReference.Id; + referenceId.Should().NotBeNull(); + return referenceId!; + } + +} + +[ApiController] +[Route("mvc/openapi")] +public sealed class OpenApiMvcController : ControllerBase +{ + [HttpGet("success")] + [ProducesPortableSuccessResponse( + TopLevelMetadataType = typeof(SuccessMetadata), + MetadataSerializationMode = MetadataSerializationMode.Always + )] + public ActionResult GetSuccess() => Ok(new MovieDto()); + + [HttpGet("problem")] + [ProducesPortableProblem( + StatusCodes.Status404NotFound, + TopLevelMetadataType = typeof(ProblemMetadata), + ErrorCodes = new[] { "VersionMismatch" } + )] + public IActionResult GetProblem() => Problem(); + + [HttpGet("validation")] + [ProducesPortableValidationProblem( + Format = ValidationProblemSerializationFormat.Rich, + ErrorCodes = new[] { "VersionMismatch" } + )] + public IActionResult GetValidation() => Problem(); +} + +public sealed class MovieDto +{ + public string Title { get; init; } = string.Empty; +} + +public sealed class SuccessMetadata +{ + public string TraceId { get; init; } = string.Empty; +} + +public sealed class ProblemMetadata +{ + public string CorrelationId { get; init; } = string.Empty; +} + +public sealed class InlineProblemMetadata +{ + public string MovieId { get; init; } = string.Empty; +} + +public sealed class VersionMismatchMetadata +{ + public string CurrentVersion { get; init; } = string.Empty; +} + +public sealed class FundsMetadata +{ + public decimal MissingAmount { get; init; } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs new file mode 100644 index 0000000..de123f8 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class PortableResultsOpenApiSchemasTests +{ + [Fact] + public void InstallInto_ShouldAddTheCanonicalSchemaCatalog() + { + var document = new OpenApiDocument(); + + PortableResultsOpenApiSchemas.InstallInto(document); + + document.Components.Should().NotBeNull(); + document.Components!.Schemas.Should().NotBeNull(); + document.Components.Schemas.Keys.Should().BeEquivalentTo( + new HashSet + { + "ErrorCategory", + "PortableError", + "PortableValidationErrorDetail", + "PortableProblemDetails", + "PortableRichValidationProblemDetails", + "PortableAspNetCoreValidationProblemDetails" + } + ); + } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json new file mode 100644 index 0000000..b809238 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json @@ -0,0 +1,305 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "heVQl5tKYnnIDYlR1QMVGueYH6iriZTcZB6AjDczQNwZzxkjDIt9C84Pt4cCiZYrbo7jkZOYGWbs6Lo9wAtVLg==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[7.2.2, 7.2.2]", + "resolved": "7.2.2", + "contentHash": "B6FJmtTadaqtLXH5qfn9hlYF5ruNOAdtjA9+1V3fuYp/MZzj7lB3Ys5cdgy72uv7w1GE5Y9PEX369UD3N1sfHg==", + "dependencies": { + "System.Configuration.ConfigurationManager": "6.0.0" + } + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MfacYQ7jNzj6073YobyoFfXpNmGqrV1UCywTM339DOcYpfalcM4K4heFjV5k3dDkKkWOGWO/DV3hdmVRqFkIxA==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.4.0, )", + "resolved": "18.4.0", + "contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.4.0", + "Microsoft.TestPlatform.TestHost": "18.4.0" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "PJEdrZnnhvxIEXzDdvdZ38GvpdaiUfKkZ99kudS8riJwhowFb/Qh26Wjk9smrCWcYdMFQmpN5epGiL4o1s8LYA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.4.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "7T+m0kDSlIPTHIkPMIu6m6tV6qsMqJpvQWW2jIc2qi7sn40qxFo0q+7mEQAhMPXZHMKnWrnv47ntGlM/ejvw3g==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "6.0.0", + "System.Security.Permissions": "6.0.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "rp1gMNEZpvx9vP0JW0oHLxlf8oSiQgtno77Y4PLUBjSiDYoD77Y8uXHr1Ea5XG4/pIKhqAdxZ8v8OTUtqo9PeQ==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "T/uuc7AklkDoxmcJ7LGkyX1CcSviZuLCa4jg3PekfJ7SU0niF0IVTXwUiNVP9DSpzou2PpxJ+eNY2IfDM90ZCg==", + "dependencies": { + "System.Windows.Extensions": "6.0.0" + } + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "IXoJOXIqc39AIe+CIR7koBtRGMiCt/LPM3lI+PELtDIy9XdyeSrwXFdWV9dzJ2Awl0paLWUaknLxFQ5HpHZUog==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "light.portableresults": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.HashCode": "[6.0.0, )", + "Ulid": "[1.4.1, )" + } + }, + "light.portableresults.aspnetcore.minimalapis": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" + } + }, + "light.portableresults.aspnetcore.mvc": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" + } + }, + "light.portableresults.aspnetcore.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "light.portableresults.aspnetcore.shared": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "hQB3Hq1LlF0NkGVNyZIvwIQIY3LM7Cw1oYjNiTvdNqmzzipVAWEK1c5sj2H5aFX0udnjgPLxSYKq2fupueS8ow==" + }, + "Microsoft.Bcl.HashCode": { + "type": "CentralTransitive", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Ulid": { + "type": "CentralTransitive", + "requested": "[1.4.1, )", + "resolved": "1.4.1", + "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" + } + } + } +} \ No newline at end of file diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/xunit.runner.json b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/xunit.runner.json new file mode 100644 index 0000000..c2f8426 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs deleted file mode 100644 index 4f24cdc..0000000 --- a/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableResultsOpenApiNamingConventionsTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; -using FluentAssertions; -using Xunit; - -namespace Light.PortableResults.AspNetCore.Shared.Tests; - -public sealed class PortableResultsOpenApiNamingConventionsTests -{ - public static TheoryData AllObjectGenericArgsCases { get; } = - new () - { - { typeof(PortableError), "PortableError" }, - { typeof(PortableValidationErrorDetail), "PortableValidationErrorDetail" }, - { typeof(PortableSuccessResponse), "PortableSuccessResponse" }, - { typeof(PortableProblemDetails), "PortableProblemDetails" }, - { - typeof(PortableRichValidationProblemDetails), - "PortableRichValidationProblemDetails" - }, - { - typeof(PortableAspNetCoreValidationProblemDetails), - "PortableAspNetCoreValidationProblemDetails" - } - }; - - public static TheoryData FallthroughCases { get; } = - new () - { - // Non-object generic args: caller's default naming should apply. - typeof(PortableError), - typeof(PortableProblemDetails), - typeof(PortableProblemDetails), - typeof(PortableSuccessResponse), - // Non-generic schema types stay with their default name. - typeof(PortableError), - typeof(PortableProblemDetails), - // Unrelated types are never handled by the helper. - typeof(string), - typeof(SomeMetadata) - }; - - [Theory] - [MemberData(nameof(AllObjectGenericArgsCases))] - public void TryCreateSchemaReferenceId_ReturnsSimpleNameWhenAllGenericArgsAreObject( - Type type, string expected - ) - { - var typeInfo = JsonTypeInfo.CreateJsonTypeInfo(type, JsonSerializerOptions.Default); - - var result = PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(typeInfo); - - result.Should().Be(expected); - } - - [Theory] - [MemberData(nameof(FallthroughCases))] - public void TryCreateSchemaReferenceId_ReturnsNullToSignalFallthrough(Type type) - { - var typeInfo = JsonTypeInfo.CreateJsonTypeInfo(type, JsonSerializerOptions.Default); - - var result = PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(typeInfo); - - result.Should().BeNull(); - } - - [Fact] - public void TryCreateSchemaReferenceId_ThrowsWhenTypeInfoIsNull() - { - Action act = () => PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId(null!); - - act.Should().Throw().WithParameterName("typeInfo"); - } - - public sealed class SomeMetadata - { - public string? Name { get; init; } - } -} diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs deleted file mode 100644 index 6aec87f..0000000 --- a/tests/Light.PortableResults.AspNetCore.Shared.Tests/PortableSchemaTypesTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Collections.Generic; -using FluentAssertions; -using Xunit; - -namespace Light.PortableResults.AspNetCore.Shared.Tests; - -public sealed class PortableSchemaTypesTests -{ - [Fact] - public void PortableError_ShouldExposeAssignedValues() - { - var sut = new PortableError - { - Message = "Validation failed", - Code = "too_short", - Target = "name", - Category = ErrorCategory.Validation, - Metadata = new { MinLength = 3 } - }; - - sut.Should().BeEquivalentTo( - new - { - Message = "Validation failed", - Code = "too_short", - Target = "name", - Category = ErrorCategory.Validation, - Metadata = new { MinLength = 3 } - } - ); - } - - [Fact] - public void PortableErrorOfT_ShouldExposeAssignedValues() - { - var sut = new PortableError - { - Message = "Validation failed", - Code = "too_short", - Target = "name", - Category = ErrorCategory.Validation, - Metadata = new ErrorMetadata { MinLength = 3 } - }; - - sut.Should().BeEquivalentTo( - new PortableError - { - Message = "Validation failed", - Code = "too_short", - Target = "name", - Category = ErrorCategory.Validation, - Metadata = new ErrorMetadata { MinLength = 3 } - } - ); - } - - [Fact] - public void PortableProblemDetails_ShouldInitializeErrorsAndExposeMetadata() - { - var metadata = new ProblemMetadata { TraceId = "trace-42" }; - var error = new PortableError - { - Message = "Not found", - Code = "not_found", - Target = "contactId", - Category = ErrorCategory.NotFound, - Metadata = new ErrorMetadata { MinLength = 0 } - }; - var sut = new PortableProblemDetails - { - Title = "Problem", - Status = 404, - Errors = new[] { error }, - Metadata = metadata - }; - - sut.Errors.Should().ContainSingle().Which.Should().BeEquivalentTo(error); - sut.Metadata.Should().BeEquivalentTo(metadata); - } - - [Fact] - public void PortableProblemDetails_DefaultVariantShouldUseObjectMetadataTypes() - { - var sut = new PortableProblemDetails(); - - sut.Errors.Should().BeEmpty(); - sut.Metadata.Should().BeNull(); - } - - [Fact] - public void PortableRichValidationProblemDetails_ShouldInitializeErrorsAndExposeMetadata() - { - var metadata = new ProblemMetadata { TraceId = "trace-43" }; - var error = new PortableError - { - Message = "Too short", - Code = "too_short", - Target = "name", - Category = ErrorCategory.Validation, - Metadata = new ErrorMetadata { MinLength = 3 } - }; - var sut = new PortableRichValidationProblemDetails - { - Title = "Validation failed", - Status = 400, - Errors = new[] { error }, - Metadata = metadata - }; - - sut.Errors.Should().ContainSingle().Which.Should().BeEquivalentTo(error); - sut.Metadata.Should().BeEquivalentTo(metadata); - } - - [Fact] - public void PortableRichValidationProblemDetails_DefaultVariantShouldUseObjectMetadataTypes() - { - var sut = new PortableRichValidationProblemDetails(); - - sut.Errors.Should().BeEmpty(); - sut.Metadata.Should().BeNull(); - } - - [Fact] - public void PortableAspNetCoreValidationProblemDetails_ShouldExposeErrorDetailsAndMetadata() - { - var errorDetail = new PortableValidationErrorDetail - { - Target = "name", - Index = 1, - Code = "too_short", - Category = ErrorCategory.Validation, - Metadata = new ErrorMetadata { MinLength = 3 } - }; - var metadata = new ProblemMetadata { TraceId = "trace-44" }; - var sut = new PortableAspNetCoreValidationProblemDetails - { - ErrorDetails = new[] { errorDetail }, - Metadata = metadata - }; - - sut.ErrorDetails.Should().ContainSingle().Which.Should().BeEquivalentTo(errorDetail); - sut.Metadata.Should().BeEquivalentTo(metadata); - } - - [Fact] - public void PortableAspNetCoreValidationProblemDetails_DefaultVariantShouldUseObjectMetadataTypes() - { - var sut = new PortableAspNetCoreValidationProblemDetails(); - - sut.ErrorDetails.Should().BeNull(); - sut.Metadata.Should().BeNull(); - } - - [Fact] - public void PortableValidationErrorDetail_ShouldExposeAssignedValues() - { - var sut = new PortableValidationErrorDetail - { - Target = "name", - Index = 2, - Code = "too_short", - Category = ErrorCategory.Validation, - Metadata = new { MinLength = 3 } - }; - - sut.Should().BeEquivalentTo( - new - { - Target = "name", - Index = 2, - Code = "too_short", - Category = ErrorCategory.Validation, - Metadata = new { MinLength = 3 } - } - ); - } - - [Fact] - public void PortableValidationErrorDetailOfT_ShouldExposeAssignedValues() - { - var sut = new PortableValidationErrorDetail - { - Target = "name", - Index = 2, - Code = "too_short", - Category = ErrorCategory.Validation, - Metadata = new ErrorMetadata { MinLength = 3 } - }; - - sut.Should().BeEquivalentTo( - new PortableValidationErrorDetail - { - Target = "name", - Index = 2, - Code = "too_short", - Category = ErrorCategory.Validation, - Metadata = new ErrorMetadata { MinLength = 3 } - } - ); - } - - [Fact] - public void PortableSuccessResponse_ShouldExposeValueAndMetadata() - { - var value = new SuccessValue { Name = "Alice" }; - var metadata = new ProblemMetadata { TraceId = "trace-45" }; - var sut = new PortableSuccessResponse - { - Value = value, - Metadata = metadata - }; - - sut.Should().BeEquivalentTo( - new PortableSuccessResponse - { - Value = value, - Metadata = metadata - } - ); - } - - [Fact] - public void PortableProblemDetails_GenericErrorsPropertyShouldAcceptReadOnlyCollections() - { - IReadOnlyList> errors = - [ - new() - { - Message = "Conflict", - Code = "duplicate", - Target = "email", - Category = ErrorCategory.Conflict, - Metadata = new ErrorMetadata { MinLength = 0 } - } - ]; - var sut = new PortableProblemDetails - { - Errors = errors, - Metadata = new ProblemMetadata { TraceId = "trace-46" } - }; - - sut.Errors.Should().BeSameAs(errors); - } - - private sealed class ErrorMetadata - { - public int MinLength { get; init; } - } - - private sealed class ProblemMetadata - { - public string TraceId { get; init; } = string.Empty; - } - - private sealed class SuccessValue - { - public string Name { get; init; } = string.Empty; - } -} From 99b02ec326956c401296faa60ebdc9b92c97199f Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 22 Apr 2026 06:56:00 +0200 Subject: [PATCH 11/67] chore: add NewMovie endpoint to the project Signed-off-by: Kenny Pflug --- .../IAddMovieRatingSession.cs | 2 +- .../InMemoryAddMovieRatingSession.cs | 2 +- .../MovieRatingJsonContext.cs | 6 +-- .../NewMovie/AddNewMovieEndpoint.cs | 35 +++++++++++++ .../NewMovie/IAddNewMovieSession.cs | 14 +++++ .../NewMovie/InMemoryAddNewMovieSession.cs | 28 ++++++++++ .../NewMovie/NewMovieDto.cs | 9 ++++ .../NewMovie/NewMovieModule.cs | 12 +++++ .../NewMovie/NewMovieService.cs | 51 +++++++++++++++++++ .../NewMovie/NewMovieValidator.cs | 20 ++++++++ samples/NativeAotMovieRating/Program.cs | 4 +- samples/NativeAotMovieRating/requests.http | 9 ++++ 12 files changed, 186 insertions(+), 6 deletions(-) rename samples/NativeAotMovieRating/{ => AddMovieRating}/IAddMovieRatingSession.cs (87%) rename samples/NativeAotMovieRating/{ => AddMovieRating}/InMemoryAddMovieRatingSession.cs (94%) create mode 100644 samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs create mode 100644 samples/NativeAotMovieRating/NewMovie/IAddNewMovieSession.cs create mode 100644 samples/NativeAotMovieRating/NewMovie/InMemoryAddNewMovieSession.cs create mode 100644 samples/NativeAotMovieRating/NewMovie/NewMovieDto.cs create mode 100644 samples/NativeAotMovieRating/NewMovie/NewMovieModule.cs create mode 100644 samples/NativeAotMovieRating/NewMovie/NewMovieService.cs create mode 100644 samples/NativeAotMovieRating/NewMovie/NewMovieValidator.cs diff --git a/samples/NativeAotMovieRating/IAddMovieRatingSession.cs b/samples/NativeAotMovieRating/AddMovieRating/IAddMovieRatingSession.cs similarity index 87% rename from samples/NativeAotMovieRating/IAddMovieRatingSession.cs rename to samples/NativeAotMovieRating/AddMovieRating/IAddMovieRatingSession.cs index 011ea48..e65e6ed 100644 --- a/samples/NativeAotMovieRating/IAddMovieRatingSession.cs +++ b/samples/NativeAotMovieRating/AddMovieRating/IAddMovieRatingSession.cs @@ -4,7 +4,7 @@ using Light.SharedCore.DatabaseAccessAbstractions; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating; +namespace NativeAotMovieRating.AddMovieRating; public interface IAddMovieRatingSession : ISession { diff --git a/samples/NativeAotMovieRating/InMemoryAddMovieRatingSession.cs b/samples/NativeAotMovieRating/AddMovieRating/InMemoryAddMovieRatingSession.cs similarity index 94% rename from samples/NativeAotMovieRating/InMemoryAddMovieRatingSession.cs rename to samples/NativeAotMovieRating/AddMovieRating/InMemoryAddMovieRatingSession.cs index e193767..c20307c 100644 --- a/samples/NativeAotMovieRating/InMemoryAddMovieRatingSession.cs +++ b/samples/NativeAotMovieRating/AddMovieRating/InMemoryAddMovieRatingSession.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating; +namespace NativeAotMovieRating.AddMovieRating; public sealed class InMemoryAddMovieRatingSession : IAddMovieRatingSession { diff --git a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs index 80eaf91..bb5e72e 100644 --- a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs +++ b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs @@ -5,6 +5,7 @@ using Light.PortableResults.Http.Writing; using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.InMemoryDatabaseAccess; +using NativeAotMovieRating.NewMovie; namespace NativeAotMovieRating.JsonSerialization; @@ -12,10 +13,9 @@ namespace NativeAotMovieRating.JsonSerialization; [JsonSerializable(typeof(MovieRatingDto))] [JsonSerializable(typeof(MovieRating))] [JsonSerializable(typeof(HttpResultForWriting))] +[JsonSerializable(typeof(HttpResultForWriting))] [JsonSerializable(typeof(List))] -// Primitive/parameter types that appear in endpoint signatures. Microsoft.AspNetCore.OpenApi -// requests JsonTypeInfo for each of them when building the OpenAPI document, and source-gen-only -// JSON (AOT mode) will not resolve them implicitly. +[JsonSerializable(typeof(NewMovieDto))] [JsonSerializable(typeof(Guid?))] [JsonSerializable(typeof(int))] public sealed partial class MovieRatingJsonContext : JsonSerializerContext; diff --git a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs new file mode 100644 index 0000000..28317fe --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace NativeAotMovieRating.NewMovie; + +public static class AddNewMovieEndpoint +{ + public static void MapNewMovieEndpoint(this WebApplication app) => + app + .MapPut("/api/movies", NewMovieRating) + .WithName("AddNewMovie") + .WithTags("Movies") + .WithSummary("Adds a new movie.") + .WithDescription( + "Validates the request and stores a new movie. Returns the stored movie on success, or a rich Light.PortableResults problem details response on validation failures." + ) + .Produces() + .ProducesPortableValidationProblem(configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)) + .ProducesPortableProblem(); + + private static async Task NewMovieRating( + NewMovieDto dto, + NewMovieService service, + CancellationToken cancellationToken = default + ) + { + var result = await service.AddNewMovieAsync(dto, cancellationToken); + return result.ToMinimalApiResult(); + } +} diff --git a/samples/NativeAotMovieRating/NewMovie/IAddNewMovieSession.cs b/samples/NativeAotMovieRating/NewMovie/IAddNewMovieSession.cs new file mode 100644 index 0000000..b027c5f --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/IAddNewMovieSession.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Light.SharedCore.DatabaseAccessAbstractions; +using NativeAotMovieRating.InMemoryDatabaseAccess; + +namespace NativeAotMovieRating.NewMovie; + +public interface IAddNewMovieSession : ISession +{ + Task GetMovieAsync(Guid movieId, CancellationToken cancellationToken = default); + + void AddMovie(Movie movie); +} diff --git a/samples/NativeAotMovieRating/NewMovie/InMemoryAddNewMovieSession.cs b/samples/NativeAotMovieRating/NewMovie/InMemoryAddNewMovieSession.cs new file mode 100644 index 0000000..5e7ac34 --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/InMemoryAddNewMovieSession.cs @@ -0,0 +1,28 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NativeAotMovieRating.InMemoryDatabaseAccess; + +namespace NativeAotMovieRating.NewMovie; + +public sealed class InMemoryAddNewMovieSession : IAddNewMovieSession +{ + private readonly InMemoryMovieDatabase _database; + + public InMemoryAddNewMovieSession(InMemoryMovieDatabase database) + { + _database = database; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetMovieAsync(Guid movieId, CancellationToken cancellationToken = default) + { + return Task.FromResult(_database.Movies.FirstOrDefault(x => x.Id == movieId)); + } + + public void AddMovie(Movie movie) => _database.Movies.Add(movie); +} diff --git a/samples/NativeAotMovieRating/NewMovie/NewMovieDto.cs b/samples/NativeAotMovieRating/NewMovie/NewMovieDto.cs new file mode 100644 index 0000000..1196dbe --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/NewMovieDto.cs @@ -0,0 +1,9 @@ +using System; + +namespace NativeAotMovieRating.NewMovie; + +public sealed record NewMovieDto +{ + public required Guid MovieId { get; init; } + public required string MovieName { get; init; } +} diff --git a/samples/NativeAotMovieRating/NewMovie/NewMovieModule.cs b/samples/NativeAotMovieRating/NewMovie/NewMovieModule.cs new file mode 100644 index 0000000..4966954 --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/NewMovieModule.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace NativeAotMovieRating.NewMovie; + +public static class NewMovieModule +{ + public static IServiceCollection AddNewMovieModule(this IServiceCollection services) => + services + .AddScoped() + .AddScoped() + .AddSingleton(); +} diff --git a/samples/NativeAotMovieRating/NewMovie/NewMovieService.cs b/samples/NativeAotMovieRating/NewMovie/NewMovieService.cs new file mode 100644 index 0000000..abe1660 --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/NewMovieService.cs @@ -0,0 +1,51 @@ +using System.Threading; +using System.Threading.Tasks; +using Light.PortableResults; +using NativeAotMovieRating.InMemoryDatabaseAccess; +using Serilog; + +namespace NativeAotMovieRating.NewMovie; + +public sealed class NewMovieService +{ + private readonly ILogger _logger; + private readonly IAddNewMovieSession _session; + private readonly NewMovieValidator _validator; + + public NewMovieService(IAddNewMovieSession session, NewMovieValidator validator, ILogger logger) + { + _session = session; + _validator = validator; + _logger = logger; + } + + public async Task> AddNewMovieAsync(NewMovieDto dto, CancellationToken cancellationToken = default) + { + var validationContext = _validator.ValidationContextFactory.CreateValidationContext(); + if (_validator.CheckForErrors(dto, validationContext, out var errorResult)) + { + return Result.Fail(errorResult.Errors); + } + + var movie = await _session.GetMovieAsync(dto.MovieId, cancellationToken); + if (movie is null) + { + var newMovie = new Movie + { + Id = dto.MovieId, + Title = dto.MovieName + }; + + _session.AddMovie(newMovie); + await _session.SaveChangesAsync(cancellationToken); + + _logger.Information( + "Successfully added new movie {MovieTitle}", + dto.MovieName + ); + return Result.Ok(newMovie); + } + + return Result.Ok(movie); + } +} diff --git a/samples/NativeAotMovieRating/NewMovie/NewMovieValidator.cs b/samples/NativeAotMovieRating/NewMovie/NewMovieValidator.cs new file mode 100644 index 0000000..fa6724f --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/NewMovieValidator.cs @@ -0,0 +1,20 @@ +using Light.PortableResults.Validation; + +namespace NativeAotMovieRating.NewMovie; + +public sealed class NewMovieValidator : Validator +{ + public NewMovieValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + NewMovieDto value + ) + { + context.Check(value.MovieId).IsNotEmpty(); + context.Check(value.MovieName).IsNotNullOrWhiteSpace(); + return checkpoint.ToValidatedValue(value); + } +} diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index 2304b69..85590bd 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -2,12 +2,12 @@ using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.GetMovies; using NativeAotMovieRating.InMemoryDatabaseAccess; using NativeAotMovieRating.JsonSerialization; +using NativeAotMovieRating.NewMovie; using NativeAotMovieRating.OpenApi; using Serilog; using Serilog.Events; @@ -27,6 +27,7 @@ .AddInMemoryDatabase() .AddGetMoviesModule() .AddAddMovieRatingModule() + .AddNewMovieModule() .AddHealthChecks() .Services .AddOpenApi(); @@ -38,6 +39,7 @@ app.MapOpenApiAndScalar(); app.MapGetMoviesEndpoint(); app.MapAddMovieRatingEndpoint(); +app.MapNewMovieEndpoint(); app.RedirectHomeToDocs(); try diff --git a/samples/NativeAotMovieRating/requests.http b/samples/NativeAotMovieRating/requests.http index 0f9e53e..440ddf9 100644 --- a/samples/NativeAotMovieRating/requests.http +++ b/samples/NativeAotMovieRating/requests.http @@ -58,3 +58,12 @@ Content-Type: application/json "comment": null, "rating": -23 } + +### New Movie +PUT http://localhost:5000/api/movies +Content-Type: application/json + +{ + "movieId": "BCAE7EBA-C09B-4F1D-9406-1C8909919E48", + "movieName": "Star Wars - A New Hope" +} From dbad3f906c1c6d4cfeccac59d9c8f451c42a2e23 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 22 Apr 2026 07:01:37 +0200 Subject: [PATCH 12/67] chore: renamed AddMovieRating to NewMovieRating Signed-off-by: Kenny Pflug --- .../AddMovieRating/AddMovieRatingModule.cs | 12 ------------ .../JsonSerialization/MovieRatingJsonContext.cs | 4 ++-- .../INewMovieRatingSession.cs} | 4 ++-- .../InMemoryNewMovieRatingSession.cs} | 6 +++--- .../NewMovieRatingDto.cs} | 4 ++-- .../NewMovieRatingEndpoint.cs} | 8 ++++---- .../NewMovieRating/NewMovieRatingModule.cs | 12 ++++++++++++ .../NewMovieRatingService.cs} | 12 ++++++------ .../NewMovieRatingValidator.cs} | 10 +++++----- samples/NativeAotMovieRating/Program.cs | 4 ++-- 10 files changed, 38 insertions(+), 38 deletions(-) delete mode 100644 samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingModule.cs rename samples/NativeAotMovieRating/{AddMovieRating/IAddMovieRatingSession.cs => NewMovieRating/INewMovieRatingSession.cs} (73%) rename samples/NativeAotMovieRating/{AddMovieRating/InMemoryAddMovieRatingSession.cs => NewMovieRating/InMemoryNewMovieRatingSession.cs} (80%) rename samples/NativeAotMovieRating/{AddMovieRating/MovieRatingDto.cs => NewMovieRating/NewMovieRatingDto.cs} (77%) rename samples/NativeAotMovieRating/{AddMovieRating/AddMovieRatingEndpoint.cs => NewMovieRating/NewMovieRatingEndpoint.cs} (88%) create mode 100644 samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingModule.cs rename samples/NativeAotMovieRating/{AddMovieRating/AddMovieRatingService.cs => NewMovieRating/NewMovieRatingService.cs} (85%) rename samples/NativeAotMovieRating/{AddMovieRating/MovieRatingValidator.cs => NewMovieRating/NewMovieRatingValidator.cs} (62%) diff --git a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingModule.cs b/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingModule.cs deleted file mode 100644 index e1b1269..0000000 --- a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingModule.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace NativeAotMovieRating.AddMovieRating; - -public static class AddMovieRatingModule -{ - public static IServiceCollection AddAddMovieRatingModule(this IServiceCollection services) => - services - .AddScoped() - .AddScoped() - .AddSingleton(); -} diff --git a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs index bb5e72e..76b1e8c 100644 --- a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs +++ b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs @@ -3,14 +3,14 @@ using System.Text.Json; using System.Text.Json.Serialization; using Light.PortableResults.Http.Writing; -using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.InMemoryDatabaseAccess; using NativeAotMovieRating.NewMovie; +using NativeAotMovieRating.NewMovieRating; namespace NativeAotMovieRating.JsonSerialization; [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] -[JsonSerializable(typeof(MovieRatingDto))] +[JsonSerializable(typeof(NewMovieRatingDto))] [JsonSerializable(typeof(MovieRating))] [JsonSerializable(typeof(HttpResultForWriting))] [JsonSerializable(typeof(HttpResultForWriting))] diff --git a/samples/NativeAotMovieRating/AddMovieRating/IAddMovieRatingSession.cs b/samples/NativeAotMovieRating/NewMovieRating/INewMovieRatingSession.cs similarity index 73% rename from samples/NativeAotMovieRating/AddMovieRating/IAddMovieRatingSession.cs rename to samples/NativeAotMovieRating/NewMovieRating/INewMovieRatingSession.cs index e65e6ed..12a2460 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/IAddMovieRatingSession.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/INewMovieRatingSession.cs @@ -4,9 +4,9 @@ using Light.SharedCore.DatabaseAccessAbstractions; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating.AddMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public interface IAddMovieRatingSession : ISession +public interface INewMovieRatingSession : ISession { Task GetMovieAsync(Guid movieId, CancellationToken cancellationToken = default); } diff --git a/samples/NativeAotMovieRating/AddMovieRating/InMemoryAddMovieRatingSession.cs b/samples/NativeAotMovieRating/NewMovieRating/InMemoryNewMovieRatingSession.cs similarity index 80% rename from samples/NativeAotMovieRating/AddMovieRating/InMemoryAddMovieRatingSession.cs rename to samples/NativeAotMovieRating/NewMovieRating/InMemoryNewMovieRatingSession.cs index c20307c..a3bfd20 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/InMemoryAddMovieRatingSession.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/InMemoryNewMovieRatingSession.cs @@ -4,13 +4,13 @@ using System.Threading.Tasks; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating.AddMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public sealed class InMemoryAddMovieRatingSession : IAddMovieRatingSession +public sealed class InMemoryNewMovieRatingSession : INewMovieRatingSession { private readonly InMemoryMovieDatabase _database; - public InMemoryAddMovieRatingSession(InMemoryMovieDatabase database) + public InMemoryNewMovieRatingSession(InMemoryMovieDatabase database) { _database = database; } diff --git a/samples/NativeAotMovieRating/AddMovieRating/MovieRatingDto.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingDto.cs similarity index 77% rename from samples/NativeAotMovieRating/AddMovieRating/MovieRatingDto.cs rename to samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingDto.cs index 4c458a3..0eccc89 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/MovieRatingDto.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingDto.cs @@ -1,8 +1,8 @@ using System; -namespace NativeAotMovieRating.AddMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public sealed record MovieRatingDto +public sealed record NewMovieRatingDto { public required Guid Id { get; init; } public required Guid MovieId { get; init; } diff --git a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs similarity index 88% rename from samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs rename to samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs index 0f09b02..55e7f0d 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs @@ -7,9 +7,9 @@ using Microsoft.AspNetCore.Http; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating.AddMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public static class AddMovieRatingEndpoint +public static class NewMovieRatingEndpoint { public static void MapAddMovieRatingEndpoint(this WebApplication app) => app @@ -25,8 +25,8 @@ public static void MapAddMovieRatingEndpoint(this WebApplication app) => .ProducesPortableProblem(); private static async Task AddMovieRating( - MovieRatingDto dto, - AddMovieRatingService service, + NewMovieRatingDto dto, + NewMovieRatingService service, CancellationToken cancellationToken = default ) { diff --git a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingModule.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingModule.cs new file mode 100644 index 0000000..1262b3e --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingModule.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace NativeAotMovieRating.NewMovieRating; + +public static class NewMovieRatingModule +{ + public static IServiceCollection AddNewMovieRatingModule(this IServiceCollection services) => + services + .AddScoped() + .AddScoped() + .AddSingleton(); +} diff --git a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingService.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingService.cs similarity index 85% rename from samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingService.cs rename to samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingService.cs index 99de434..71bf350 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingService.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingService.cs @@ -5,15 +5,15 @@ using NativeAotMovieRating.InMemoryDatabaseAccess; using Serilog; -namespace NativeAotMovieRating.AddMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public sealed class AddMovieRatingService +public sealed class NewMovieRatingService { private readonly ILogger _logger; - private readonly IAddMovieRatingSession _session; - private readonly MovieRatingValidator _validator; + private readonly INewMovieRatingSession _session; + private readonly NewMovieRatingValidator _validator; - public AddMovieRatingService(MovieRatingValidator validator, IAddMovieRatingSession session, ILogger logger) + public NewMovieRatingService(NewMovieRatingValidator validator, INewMovieRatingSession session, ILogger logger) { _validator = validator; _session = session; @@ -21,7 +21,7 @@ public AddMovieRatingService(MovieRatingValidator validator, IAddMovieRatingSess } public async Task> AddMovieRatingAsync( - MovieRatingDto dto, + NewMovieRatingDto dto, CancellationToken cancellationToken = default ) { diff --git a/samples/NativeAotMovieRating/AddMovieRating/MovieRatingValidator.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs similarity index 62% rename from samples/NativeAotMovieRating/AddMovieRating/MovieRatingValidator.cs rename to samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs index 4350f93..9bd9483 100644 --- a/samples/NativeAotMovieRating/AddMovieRating/MovieRatingValidator.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs @@ -1,16 +1,16 @@ using Light.PortableResults.Validation; -namespace NativeAotMovieRating.AddMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public sealed class MovieRatingValidator : Validator +public sealed class NewMovieRatingValidator : Validator { - public MovieRatingValidator(IValidationContextFactory validationContextFactory) + public NewMovieRatingValidator(IValidationContextFactory validationContextFactory) : base(validationContextFactory) { } - protected override ValidatedValue PerformValidation( + protected override ValidatedValue PerformValidation( ValidationContext context, ValidationCheckpoint checkpoint, - MovieRatingDto dto + NewMovieRatingDto dto ) { context.Check(dto.Id).IsNotEmpty(); diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index 85590bd..cf453bd 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -3,11 +3,11 @@ using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using NativeAotMovieRating.AddMovieRating; using NativeAotMovieRating.GetMovies; using NativeAotMovieRating.InMemoryDatabaseAccess; using NativeAotMovieRating.JsonSerialization; using NativeAotMovieRating.NewMovie; +using NativeAotMovieRating.NewMovieRating; using NativeAotMovieRating.OpenApi; using Serilog; using Serilog.Events; @@ -26,7 +26,7 @@ .ConfigureJsonSerialization() .AddInMemoryDatabase() .AddGetMoviesModule() - .AddAddMovieRatingModule() + .AddNewMovieRatingModule() .AddNewMovieModule() .AddHealthChecks() .Services From d7042ec70ca849201b98b5343e80d7b20d4390c6 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 22 Apr 2026 07:21:14 +0200 Subject: [PATCH 13/67] chore: update to .NET SDK 10.0.202 Signed-off-by: Kenny Pflug --- benchmarks/Benchmarks/packages.lock.json | 8 +++---- global.json | 2 +- .../NativeAotMovieRating/packages.lock.json | 24 +++++++++---------- .../packages.lock.json | 12 +++------- .../packages.lock.json | 2 +- .../packages.lock.json | 12 +++------- .../packages.lock.json | 12 +++------- .../packages.lock.json | 4 ++-- .../packages.lock.json | 4 ++-- .../packages.lock.json | 2 +- .../packages.lock.json | 2 +- 11 files changed, 33 insertions(+), 51 deletions(-) diff --git a/benchmarks/Benchmarks/packages.lock.json b/benchmarks/Benchmarks/packages.lock.json index 3f5c356..e7472f3 100644 --- a/benchmarks/Benchmarks/packages.lock.json +++ b/benchmarks/Benchmarks/packages.lock.json @@ -136,25 +136,25 @@ "light.portableresults.aspnetcore.minimalapis": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.1.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" } }, "light.portableresults.aspnetcore.mvc": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.1.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" } }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.1.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "light.portableresults.validation": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.1.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.HashCode": { diff --git a/global.json b/global.json index 81bc135..b8c3076 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", + "version": "10.0.202", "rollForward": "disable" } } diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index a2ee1c7..770a2fc 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -22,15 +22,15 @@ }, "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "nBOzxOys8OeyJ+Nsi/uYlI/5TSsvwjaM/p5m4dTL6khCLx9UuP3b2ec3HeuBw/+F7hHCAZG1yFx8VBeoRAX+EQ==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" }, "Scalar.AspNetCore": { "type": "Direct", @@ -179,17 +179,17 @@ "net10.0/osx-arm64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==", + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "nBOzxOys8OeyJ+Nsi/uYlI/5TSsvwjaM/p5m4dTL6khCLx9UuP3b2ec3HeuBw/+F7hHCAZG1yFx8VBeoRAX+EQ==", "dependencies": { - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.3" + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.6" } }, "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4bZ2RJrwpq/rqEBaiAn7gsqotp5jOGSu+P5fBSUMlRpmCkWnnNTCdDIBwCBemW1QkhH83d2JS9Bz71ng6oWY0g==" + "resolved": "10.0.6", + "contentHash": "+yovwOAlIpfIcH+ZWmLYXWTSWYJ93wcQxF/RVk+X4MXgLASeosCJYVLqP20g0cufKjoRqvCmnklR6y9Su3ORtA==" } } } diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json index 9c4a2fc..bf2f1fa 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json @@ -2,17 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.DotNet.ILCompiler": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" - }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", diff --git a/src/Light.PortableResults.AspNetCore.Mvc/packages.lock.json b/src/Light.PortableResults.AspNetCore.Mvc/packages.lock.json index 7aadae9..d31ac87 100644 --- a/src/Light.PortableResults.AspNetCore.Mvc/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.Mvc/packages.lock.json @@ -41,7 +41,7 @@ "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.3.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.HashCode": { diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json index 483fd3e..faa7758 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json @@ -11,17 +11,11 @@ "Microsoft.OpenApi": "2.0.0" } }, - "Microsoft.DotNet.ILCompiler": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" - }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", diff --git a/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json index 05ff998..4a1fc9d 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json @@ -2,17 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Microsoft.DotNet.ILCompiler": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" - }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", diff --git a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/packages.lock.json b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/packages.lock.json index 15c664e..8168971 100644 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/packages.lock.json +++ b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/packages.lock.json @@ -605,13 +605,13 @@ "light.portableresults.aspnetcore.minimalapis": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.3.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" } }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.3.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.AsyncInterfaces": { diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/packages.lock.json b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/packages.lock.json index 3aa4dab..36c356a 100644 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/packages.lock.json +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/packages.lock.json @@ -605,13 +605,13 @@ "light.portableresults.aspnetcore.mvc": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.3.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" } }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.3.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.AsyncInterfaces": { diff --git a/tests/Light.PortableResults.AspNetCore.Shared.Tests/packages.lock.json b/tests/Light.PortableResults.AspNetCore.Shared.Tests/packages.lock.json index a5b3d68..73d7077 100644 --- a/tests/Light.PortableResults.AspNetCore.Shared.Tests/packages.lock.json +++ b/tests/Light.PortableResults.AspNetCore.Shared.Tests/packages.lock.json @@ -233,7 +233,7 @@ "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.3.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.AsyncInterfaces": { diff --git a/tests/Light.PortableResults.Validation.Tests/packages.lock.json b/tests/Light.PortableResults.Validation.Tests/packages.lock.json index 7883eb9..b07874e 100644 --- a/tests/Light.PortableResults.Validation.Tests/packages.lock.json +++ b/tests/Light.PortableResults.Validation.Tests/packages.lock.json @@ -242,7 +242,7 @@ "light.portableresults.validation": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.3.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "Microsoft.Bcl.AsyncInterfaces": { From d042fb8f4a2c1599b52d304a70e4964e816ea7ec Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 22 Apr 2026 07:24:57 +0200 Subject: [PATCH 14/67] chore: remove native AOT dependencies from NativeAotMovieRating's packages.lock.json Signed-off-by: Kenny Pflug --- .../NativeAotMovieRating/packages.lock.json | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 770a2fc..10fe4d6 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -175,22 +175,6 @@ "resolved": "1.4.1", "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } - }, - "net10.0/osx-arm64": { - "Microsoft.DotNet.ILCompiler": { - "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "nBOzxOys8OeyJ+Nsi/uYlI/5TSsvwjaM/p5m4dTL6khCLx9UuP3b2ec3HeuBw/+F7hHCAZG1yFx8VBeoRAX+EQ==", - "dependencies": { - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.6" - } - }, - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { - "type": "Transitive", - "resolved": "10.0.6", - "contentHash": "+yovwOAlIpfIcH+ZWmLYXWTSWYJ93wcQxF/RVk+X4MXgLASeosCJYVLqP20g0cufKjoRqvCmnklR6y9Su3ORtA==" - } } } -} \ No newline at end of file +} From f2850ed398c1f90375418993a7fa87e17ae4b4de Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 22 Apr 2026 07:29:35 +0200 Subject: [PATCH 15/67] chore: turn RestoreLockedMode off for NativeAotMovieRating project Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/NativeAotMovieRating.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index d01484b..adedf7f 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -7,6 +7,7 @@ $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated true + false From d7efd05e26958d76c3a388a67fbcdbb1560afebe Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Mon, 27 Apr 2026 06:52:27 +0200 Subject: [PATCH 16/67] chore: update .NET SDK version to 10.0.203 Signed-off-by: Kenny Pflug --- global.json | 2 +- .../NativeAotMovieRating/packages.lock.json | 30 ++++++++++++++----- .../packages.lock.json | 6 ++-- .../packages.lock.json | 6 ++-- .../packages.lock.json | 6 ++-- 5 files changed, 33 insertions(+), 17 deletions(-) diff --git a/global.json b/global.json index b8c3076..dcdef4f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.202", + "version": "10.0.203", "rollForward": "disable" } } diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 10fe4d6..cfb896b 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -22,15 +22,15 @@ }, "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "nBOzxOys8OeyJ+Nsi/uYlI/5TSsvwjaM/p5m4dTL6khCLx9UuP3b2ec3HeuBw/+F7hHCAZG1yFx8VBeoRAX+EQ==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" }, "Scalar.AspNetCore": { "type": "Direct", @@ -175,6 +175,22 @@ "resolved": "1.4.1", "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } + }, + "net10.0/osx-arm64": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==", + "dependencies": { + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.7" + } + }, + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "ycFCaZwEvd0nNqcW53l0KWM+fz74owXpWj5C/z0GjznwAtHwmGTeh3vGTGFrXD9LEagX8G3cHRtzGDrTabIrwQ==" + } } } -} +} \ No newline at end of file diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json index bf2f1fa..2b375fb 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json index faa7758..7b9d65b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json @@ -13,9 +13,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", diff --git a/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json index 4a1fc9d..a608ab4 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json +++ b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", From 3af47a7e52c35e356d7245903626bbbb1a76cd33 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Apr 2026 12:38:33 +0200 Subject: [PATCH 17/67] chore: move PortableOpenApiErrorResponseAttributeBase to own file Signed-off-by: Kenny Pflug --- ...rtableOpenApiErrorResponseAttributeBase.cs | 37 +++++++++++++++++++ .../PortableOpenApiResponseAttributeBase.cs | 34 ----------------- 2 files changed, 37 insertions(+), 34 deletions(-) create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs new file mode 100644 index 0000000..c16fbd0 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs @@ -0,0 +1,37 @@ +using System; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Base class for Light.PortableResults error-response OpenAPI metadata. +/// +public abstract class PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase +{ + /// + /// Initializes a new instance of . + /// + /// The response kind documented by the attribute. + /// The associated HTTP status code. + /// The associated content type. + protected PortableOpenApiErrorResponseAttributeBase( + PortableOpenApiResponseKind kind, + int statusCode, + string contentType + ) : base(kind, statusCode, contentType) { } + + /// + /// Gets or sets the globally registered error codes that should be narrowed on the response. + /// + public string[]? ErrorCodes { get; set; } + + /// + /// Gets or sets the inline error codes whose metadata schema is defined directly on the endpoint. + /// + public string[]? InlineErrorMetadataCodes { get; set; } + + /// + /// Gets or sets the inline metadata CLR types aligned by index with + /// . + /// + public Type[]? InlineErrorMetadataTypes { get; set; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs index 6b156ac..f70a773 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs @@ -45,37 +45,3 @@ string contentType /// public Type? TopLevelMetadataType { get; set; } } - -/// -/// Base class for Light.PortableResults error-response OpenAPI metadata. -/// -public abstract class PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase -{ - /// - /// Initializes a new instance of . - /// - /// The response kind documented by the attribute. - /// The associated HTTP status code. - /// The associated content type. - protected PortableOpenApiErrorResponseAttributeBase( - PortableOpenApiResponseKind kind, - int statusCode, - string contentType - ) : base(kind, statusCode, contentType) { } - - /// - /// Gets or sets the globally registered error codes that should be narrowed on the response. - /// - public string[]? ErrorCodes { get; set; } - - /// - /// Gets or sets the inline error codes whose metadata schema is defined directly on the endpoint. - /// - public string[]? InlineErrorMetadataCodes { get; set; } - - /// - /// Gets or sets the inline metadata CLR types aligned by index with - /// . - /// - public Type[]? InlineErrorMetadataTypes { get; set; } -} From 94444a1f2ea351e29a5b0336156a039649ad523c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Apr 2026 12:38:55 +0200 Subject: [PATCH 18/67] chore: cleanup PortableResultsOpenApiModule Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiModule.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs index 9b52450..8574a03 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using Light.PortableResults.Http.Writing; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; From 41abc868318fd64ab112fd71bc2850ad424da3f1 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Apr 2026 12:41:29 +0200 Subject: [PATCH 19/67] chore: OpenAPI attributes are no longer sealed Signed-off-by: Kenny Pflug --- .../ProducesPortableProblemAttribute.cs | 2 +- .../ProducesPortableSuccessResponseAttribute.cs | 10 ++++------ .../ProducesPortableValidationProblemAttribute.cs | 13 +++++++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs index 5577d28..38df184 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs @@ -7,7 +7,7 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// Documents a Light.PortableResults problem details response. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] -public sealed class ProducesPortableProblemAttribute : PortableOpenApiErrorResponseAttributeBase +public class ProducesPortableProblemAttribute : PortableOpenApiErrorResponseAttributeBase { /// /// Initializes a new instance of . diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs index 5bfd032..2750cfb 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs @@ -9,11 +9,9 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// /// The response value type. [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] -public sealed class ProducesPortableSuccessResponseAttribute : PortableOpenApiResponseAttributeBase - , IPortableSuccessResponseOpenApiAttribute +public class ProducesPortableSuccessResponseAttribute + : PortableOpenApiResponseAttributeBase, IPortableSuccessResponseOpenApiAttribute { - private MetadataSerializationMode _metadataSerializationMode; - /// /// Initializes a new instance of . /// @@ -37,10 +35,10 @@ public ProducesPortableSuccessResponseAttribute( /// public MetadataSerializationMode MetadataSerializationMode { - get => _metadataSerializationMode; + get; set { - _metadataSerializationMode = value; + field = value; HasMetadataSerializationModeOverride = true; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs index 6e72475..216563b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs @@ -8,10 +8,8 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// Documents a Light.PortableResults validation problem response. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] -public sealed class ProducesPortableValidationProblemAttribute : PortableOpenApiErrorResponseAttributeBase +public class ProducesPortableValidationProblemAttribute : PortableOpenApiErrorResponseAttributeBase { - private ValidationProblemSerializationFormat _format; - /// /// Initializes a new instance of . /// @@ -27,13 +25,16 @@ public ProducesPortableValidationProblemAttribute( /// public ValidationProblemSerializationFormat Format { - get => _format; + get; set { - _format = value; + field = value; HasFormatOverride = true; } } - internal bool HasFormatOverride { get; private set; } + /// + /// Indicates whether the serialization format for the validation problem response has been explicitly overridden. + /// + public bool HasFormatOverride { get; private set; } } From 887738f9d5cda9366381aaba325c3b98d27fc406 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Apr 2026 12:42:20 +0200 Subject: [PATCH 20/67] chore: OpenAPI core functionality is no longer internal Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiSchemaNaming.cs | 71 ++++++++++++++++--- .../PortableResultsOpenApiSchemas.cs | 50 ++++++++++--- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs index 3ad182b..8499fa3 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs @@ -4,9 +4,21 @@ namespace Light.PortableResults.AspNetCore.OpenApi; -internal static class PortableResultsOpenApiSchemaNaming +/// +/// Provides helpers for creating stable OpenAPI component schema ids used by Light.PortableResults. +/// +public static class PortableResultsOpenApiSchemaNaming { - internal static string CreateDerivedEnvelopeSchemaId( + /// + /// Creates a schema id for an endpoint-specific response envelope derived from a canonical base schema. + /// + /// The canonical base schema name. + /// The OpenAPI operation that owns the derived schema. + /// The ASP.NET API description for the operation. + /// The documented HTTP status code. + /// The documented content type. + /// The derived schema id. + public static string CreateDerivedEnvelopeSchemaId( string canonicalName, OpenApiOperation operation, ApiDescription apiDescription, @@ -18,7 +30,17 @@ string contentType return $"{canonicalName}__{operationToken}__{statusCode}__{SanitizeSegment(contentType)}"; } - internal static string CreateInlineErrorSchemaId( + /// + /// Creates a schema id for an endpoint-specific error-item variant declared inline on an endpoint. + /// + /// The canonical base schema name for the error item. + /// The OpenAPI operation that owns the derived schema. + /// The ASP.NET API description for the operation. + /// The documented HTTP status code. + /// The documented content type. + /// The error code represented by the schema. + /// The inline error schema id. + public static string CreateInlineErrorSchemaId( string baseSchemaName, OpenApiOperation operation, ApiDescription apiDescription, @@ -32,30 +54,56 @@ string errorCode $"{baseSchemaName}__{operationToken}__{statusCode}__{SanitizeSegment(contentType)}__{SanitizeErrorCode(errorCode)}"; } - internal static string CreateGlobalErrorSchemaId(string baseSchemaName, string errorCode) + /// + /// Creates a schema id for a globally registered error-code-specific schema. + /// + /// The canonical base schema name for the error item. + /// The registered error code. + /// The global error schema id. + public static string CreateGlobalErrorSchemaId(string baseSchemaName, string errorCode) { return $"{baseSchemaName}__{SanitizeErrorCode(errorCode)}"; } - internal static string CreateMetadataSchemaId(string ownerSchemaId) + /// + /// Creates a schema id for a metadata schema owned by another schema component. + /// + /// The schema id of the owning component. + /// The metadata schema id. + public static string CreateMetadataSchemaId(string ownerSchemaId) { return $"{ownerSchemaId}__Metadata"; } - internal static string EscapeJsonPointer(string value) + /// + /// Escapes a value for use inside a JSON Pointer segment. + /// + /// The raw segment value. + /// The escaped JSON Pointer value. + public static string EscapeJsonPointer(string value) { ArgumentNullException.ThrowIfNull(value); return value.Replace("~", "~0", StringComparison.Ordinal) .Replace("/", "~1", StringComparison.Ordinal); } - internal static string SanitizeErrorCode(string value) + /// + /// Sanitizes an error code so it can be embedded safely into an OpenAPI component schema id. + /// + /// The raw error code. + /// The sanitized error code. + public static string SanitizeErrorCode(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); return SanitizeSegment(value); } - internal static string SanitizeSegment(string value) + /// + /// Replaces characters that are unsuitable for component schema ids with underscores. + /// + /// The raw segment value. + /// The sanitized segment. + public static string SanitizeSegment(string value) { ArgumentNullException.ThrowIfNull(value); @@ -69,7 +117,12 @@ internal static string SanitizeSegment(string value) return new string(buffer); } - internal static string SanitizeRoutePattern(string routePattern) + /// + /// Sanitizes a route pattern into a compact token suitable for schema ids. + /// + /// The raw route pattern. + /// The sanitized route token. + public static string SanitizeRoutePattern(string routePattern) { ArgumentNullException.ThrowIfNull(routePattern); diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs index d14109a..88376f0 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs @@ -11,13 +11,37 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// public static class PortableResultsOpenApiSchemas { - internal const string PortableErrorSchemaId = "PortableError"; - internal const string PortableValidationErrorDetailSchemaId = "PortableValidationErrorDetail"; - internal const string PortableProblemDetailsSchemaId = "PortableProblemDetails"; - internal const string PortableRichValidationProblemDetailsSchemaId = "PortableRichValidationProblemDetails"; - internal const string PortableAspNetCoreValidationProblemDetailsSchemaId = + /// + /// The component schema id for the canonical portable error item shape. + /// + public const string PortableErrorSchemaId = "PortableError"; + + /// + /// The component schema id for the canonical portable validation error detail shape. + /// + public const string PortableValidationErrorDetailSchemaId = "PortableValidationErrorDetail"; + + /// + /// The component schema id for the canonical portable problem details envelope. + /// + public const string PortableProblemDetailsSchemaId = "PortableProblemDetails"; + + /// + /// The component schema id for the rich validation-problem envelope. + /// + public const string PortableRichValidationProblemDetailsSchemaId = "PortableRichValidationProblemDetails"; + + + /// + /// The component schema id for the ASP.NET Core-compatible validation-problem envelope. + /// + public const string PortableAspNetCoreValidationProblemDetailsSchemaId = "PortableAspNetCoreValidationProblemDetails"; - internal const string ErrorCategorySchemaId = "ErrorCategory"; + + /// + /// The component schema id for the enum values. + /// + public const string ErrorCategorySchemaId = "ErrorCategory"; /// /// Installs the canonical Light.PortableResults schema catalog into the specified document. @@ -50,7 +74,11 @@ public static void InstallInto(OpenApiDocument document) ); } - internal static OpenApiSchema CreateOpenMetadataSchema() + /// + /// Creates an open-ended metadata schema that allows arbitrary object properties. + /// + /// The metadata schema. + public static OpenApiSchema CreateOpenMetadataSchema() { return new OpenApiSchema { @@ -59,7 +87,13 @@ internal static OpenApiSchema CreateOpenMetadataSchema() }; } - internal static OpenApiSchemaReference CreateSchemaReference(OpenApiDocument document, string schemaId) + /// + /// Creates a reference to a schema component in the specified OpenAPI document. + /// + /// The OpenAPI document that owns the schema component. + /// The component schema id to reference. + /// The schema reference. + public static OpenApiSchemaReference CreateSchemaReference(OpenApiDocument document, string schemaId) { return new OpenApiSchemaReference(schemaId, document, externalResource: null); } From 33f7157c75100f9afe5409e74ed24fcea9c8d2c2 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Apr 2026 12:42:49 +0200 Subject: [PATCH 21/67] chore: add explaining comments to PortableResultsOpenApiDocumentTransformer Signed-off-by: Kenny Pflug --- ...rtableResultsOpenApiDocumentTransformer.cs | 151 ++++++++++-------- 1 file changed, 87 insertions(+), 64 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index 3824fed..c21f737 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using System.Net.Http; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Light.PortableResults.Http.Writing; @@ -19,8 +22,8 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocumentTransformer { - private readonly IOptions _writeOptions; private readonly IPortableErrorMetadataContractRegistry _errorMetadataContractRegistry; + private readonly IOptions _writeOptions; /// /// Initializes a new instance of . @@ -44,11 +47,16 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(context); + // Install the library's fixed base schema catalog first, then add code-specific variants for + // globally registered error contracts. InstallInto only adds the canonical shared shapes; + // it does not know about application-defined error codes or their metadata CLR types. PortableResultsOpenApiSchemas.InstallInto(document); var openApiOptionsMonitor = context.ApplicationServices.GetRequiredService>(); var openApiVersion = openApiOptionsMonitor.Get(context.DocumentName).OpenApiVersion; await EnsureGlobalErrorContractSchemasAsync(document, context, openApiVersion, cancellationToken); + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + // document.Paths can be null if no paths are defined in the OpenAPI document if (document.Paths is null) { return; @@ -56,29 +64,24 @@ CancellationToken cancellationToken foreach (var apiDescription in context.DescriptionGroups.SelectMany(static group => group.Items)) { - var attributes = apiDescription.ActionDescriptor.EndpointMetadata? - .OfType() - .ToArray() ?? - []; - if (attributes.Length == 0) - { - continue; - } + var attributes = apiDescription + .ActionDescriptor + .EndpointMetadata + .OfType() + .ToArray(); - if (!TryGetOperation(document, apiDescription, out var operation)) + if (attributes.Length > 0 && TryGetOperation(document, apiDescription, out var operation)) { - continue; + await ApplyResponseMetadataAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attributes, + cancellationToken + ); } - - await ApplyResponseMetadataAsync( - document, - context, - openApiVersion, - apiDescription, - operation, - attributes, - cancellationToken - ); } } @@ -89,6 +92,10 @@ private async Task EnsureGlobalErrorContractSchemasAsync( CancellationToken cancellationToken ) { + // Pre-register schema components for every globally configured error code so endpoint-level + // documentation can later reference stable component ids instead of creating duplicates on demand. + // Each contract produces two variants because regular problems use PortableError items while + // ASP.NET-compatible validation problems use PortableValidationErrorDetail items. foreach (var (errorCode, metadataType) in _errorMetadataContractRegistry.Contracts) { var portableErrorSchemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId( @@ -133,9 +140,14 @@ private async Task ApplyResponseMetadataAsync( CancellationToken cancellationToken ) { - var responseGroups = attributes.GroupBy(static attribute => new ResponseGroupKey(attribute.StatusCode, attribute.ContentType)); + var responseGroups = attributes.GroupBy( + static attribute => new ResponseGroupKey(attribute.StatusCode, attribute.ContentType) + ); foreach (var responseGroup in responseGroups) { + // A single HTTP response slot may combine different response kinds via anyOf, but it cannot + // describe the same kind twice for the same status code and content type because the resulting + // schema would be ambiguous and we would not know which marker should win. foreach (var duplicateGroup in responseGroup.GroupBy(static attribute => attribute.Kind)) { if (duplicateGroup.Count() <= 1) @@ -148,6 +160,9 @@ CancellationToken cancellationToken ); } + // "Contributing" is local terminology here: each response attribute yields one candidate + // schema for this response slot, and the final response schema is either that single schema + // or an anyOf composed from all contributed candidates. var contributingSchemas = new List(responseGroup.Count()); foreach (var attribute in responseGroup) { @@ -167,9 +182,9 @@ CancellationToken cancellationToken response.Content ??= new Dictionary(StringComparer.Ordinal); response.Content[responseGroup.Key.ContentType] = new OpenApiMediaType { - Schema = contributingSchemas.Count == 1 - ? contributingSchemas[0] - : new OpenApiSchema { AnyOf = contributingSchemas } + Schema = contributingSchemas.Count == 1 ? + contributingSchemas[0] : + new OpenApiSchema { AnyOf = contributingSchemas } }; } } @@ -184,6 +199,9 @@ private async Task CreateContributingSchemaAsync( CancellationToken cancellationToken ) { + // By this point the transformer only cares about the discovered response metadata kind. The + // attribute may have originated from an MVC action attribute or from a Minimal API helper that + // attached the same attribute via EndpointMetadata, but both flow through the same schema builder. return attribute.Kind switch { PortableOpenApiResponseKind.SuccessResponse => @@ -225,9 +243,9 @@ CancellationToken cancellationToken ); } - var metadataSerializationMode = successAttribute.HasMetadataSerializationModeOverride - ? successAttribute.MetadataSerializationMode - : _writeOptions.Value.MetadataSerializationMode; + var metadataSerializationMode = successAttribute.HasMetadataSerializationModeOverride ? + successAttribute.MetadataSerializationMode : + _writeOptions.Value.MetadataSerializationMode; if (attribute.TopLevelMetadataType is not null && metadataSerializationMode == MetadataSerializationMode.ErrorsOnly) { @@ -255,9 +273,9 @@ CancellationToken cancellationToken attribute.StatusCode, attribute.ContentType ); - IOpenApiSchema metadataSchema = attribute.TopLevelMetadataType is null - ? PortableResultsOpenApiSchemas.CreateOpenMetadataSchema() - : await GetStableSchemaReferenceAsync( + IOpenApiSchema metadataSchema = attribute.TopLevelMetadataType is null ? + PortableResultsOpenApiSchemas.CreateOpenMetadataSchema() : + await GetStableSchemaReferenceAsync( document, context, attribute.TopLevelMetadataType, @@ -336,9 +354,10 @@ CancellationToken cancellationToken if (documentedErrorSchema is not null) { - var propertyName = canonicalSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId - ? "errorDetails" - : "errors"; + var propertyName = + canonicalSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId ? + "errorDetails" : + "errors"; extensionSchema.Properties[propertyName] = new OpenApiSchema { Type = JsonSchemaType.Array, @@ -518,16 +537,16 @@ private static OpenApiSchema CreateCodeSpecificSchema( OpenApiSpecVersion openApiVersion ) { - var codeSchema = openApiVersion >= OpenApiSpecVersion.OpenApi3_1 - ? new OpenApiSchema + var codeSchema = openApiVersion >= OpenApiSpecVersion.OpenApi3_1 ? + new OpenApiSchema { Type = JsonSchemaType.String, Const = errorCode - } - : new OpenApiSchema + } : + new OpenApiSchema { Type = JsonSchemaType.String, - Enum = [System.Text.Json.Nodes.JsonValue.Create(errorCode)!] + Enum = [JsonValue.Create(errorCode)!] }; return new OpenApiSchema @@ -586,10 +605,12 @@ private static IDictionary EnsureSchemaStore(OpenApiDocu private static bool TryGetOperation( OpenApiDocument document, ApiDescription apiDescription, - out OpenApiOperation operation + [NotNullWhen(true)] out OpenApiOperation? operation ) { - operation = default!; + operation = null; + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if (document.Paths is null) { return false; @@ -601,12 +622,13 @@ out OpenApiOperation operation return false; } - var httpMethod = string.IsNullOrWhiteSpace(apiDescription.HttpMethod) - ? null - : new HttpMethod(apiDescription.HttpMethod); - if (httpMethod is null || pathItem.Operations is null || - !pathItem.Operations.TryGetValue(httpMethod, out var resolvedOperation) || - resolvedOperation is null) + var httpMethod = string.IsNullOrWhiteSpace(apiDescription.HttpMethod) ? + null : + new HttpMethod(apiDescription.HttpMethod); + + if (httpMethod is null || + pathItem.Operations is null || + !pathItem.Operations.TryGetValue(httpMethod, out var resolvedOperation)) { return false; } @@ -618,7 +640,7 @@ out OpenApiOperation operation private static OpenApiResponse GetOrCreateResponse(OpenApiOperation operation, int statusCode) { operation.Responses ??= new OpenApiResponses(); - var responseKey = statusCode.ToString(System.Globalization.CultureInfo.InvariantCulture); + var responseKey = statusCode.ToString(CultureInfo.InvariantCulture); if (!operation.Responses.TryGetValue(responseKey, out var response)) { response = new OpenApiResponse @@ -639,19 +661,20 @@ private string ResolveCanonicalErrorEnvelopeSchemaId(PortableOpenApiResponseAttr } var validationAttribute = (ProducesPortableValidationProblemAttribute) attribute; - var format = validationAttribute.HasFormatOverride - ? validationAttribute.Format - : _writeOptions.Value.ValidationProblemSerializationFormat; - return format == ValidationProblemSerializationFormat.AspNetCoreCompatible - ? PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId - : PortableResultsOpenApiSchemas.PortableRichValidationProblemDetailsSchemaId; + var format = validationAttribute.HasFormatOverride ? + validationAttribute.Format : + _writeOptions.Value.ValidationProblemSerializationFormat; + return format == ValidationProblemSerializationFormat.AspNetCoreCompatible ? + PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId : + PortableResultsOpenApiSchemas.PortableRichValidationProblemDetailsSchemaId; } private static string ResolveErrorItemSchemaId(string canonicalEnvelopeSchemaId) { - return canonicalEnvelopeSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId - ? PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId - : PortableResultsOpenApiSchemas.PortableErrorSchemaId; + return canonicalEnvelopeSchemaId == + PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId ? + PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId : + PortableResultsOpenApiSchemas.PortableErrorSchemaId; } private static void ValidateInlineMetadataArrays(PortableOpenApiErrorResponseAttributeBase attribute) @@ -696,13 +719,6 @@ Type metadataType rawCodeTypes.Add(code, metadataType); } - private readonly record struct ResponseGroupKey(int StatusCode, string ContentType); - - private readonly record struct DocumentedErrorVariant( - string RawCode, - OpenApiSchemaReference SchemaReference - ); - private static string? NormalizePath(string? relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) @@ -735,4 +751,11 @@ private static string NormalizeRouteSegment(string segment) return "{" + content[..constraintSeparatorIndex] + "}"; } + + private readonly record struct ResponseGroupKey(int StatusCode, string ContentType); + + private readonly record struct DocumentedErrorVariant( + string RawCode, + OpenApiSchemaReference SchemaReference + ); } From 08f4ac6f5d8f84613030707de166929c8005fca0 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 09:11:00 +0200 Subject: [PATCH 22/67] refactor: introduce PortableOpenApiSuccessResponseAttributeBase to remove internal IPortableSuccessResponseOpenApiAttribute Signed-off-by: Kenny Pflug --- .../InternalOpenApiAttributeInterfaces.cs | 11 ----- ...ableOpenApiSuccessResponseAttributeBase.cs | 46 +++++++++++++++++++ ...rtableResultsOpenApiDocumentTransformer.cs | 36 ++++++--------- .../PortableSuccessResponseOpenApiBuilder.cs | 4 +- ...roducesPortableSuccessResponseAttribute.cs | 32 +------------ ...eResultsOpenApiDocumentTransformerTests.cs | 24 ++++++++++ 6 files changed, 89 insertions(+), 64 deletions(-) delete mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs b/src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs deleted file mode 100644 index 74391ba..0000000 --- a/src/Light.PortableResults.AspNetCore.OpenApi/InternalOpenApiAttributeInterfaces.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using Light.PortableResults.SharedJsonSerialization; - -namespace Light.PortableResults.AspNetCore.OpenApi; - -internal interface IPortableSuccessResponseOpenApiAttribute -{ - Type ValueType { get; } - MetadataSerializationMode MetadataSerializationMode { get; } - bool HasMetadataSerializationModeOverride { get; } -} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs new file mode 100644 index 0000000..0ea011b --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs @@ -0,0 +1,46 @@ +using System; +using Light.PortableResults.SharedJsonSerialization; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Base class for Light.PortableResults success-response OpenAPI metadata. +/// +public abstract class PortableOpenApiSuccessResponseAttributeBase : PortableOpenApiResponseAttributeBase +{ + /// + /// Initializes a new instance of . + /// + /// The associated HTTP status code. + /// The associated content type. + /// The response value type. + protected PortableOpenApiSuccessResponseAttributeBase(int statusCode, string contentType, Type valueType) + : base(PortableOpenApiResponseKind.SuccessResponse, statusCode, contentType) + { + ArgumentNullException.ThrowIfNull(valueType); + ValueType = valueType; + } + + /// + /// Gets the response value type. + /// + public Type ValueType { get; } + + /// + /// Gets or sets the optional documentation-only override for the metadata serialization mode. + /// + public MetadataSerializationMode MetadataSerializationMode + { + get; + set + { + field = value; + HasMetadataSerializationModeOverride = true; + } + } + + /// + /// Indicates whether the metadata serialization mode has been explicitly overridden. + /// + public bool HasMetadataSerializationModeOverride { get; private set; } +} \ No newline at end of file diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index c21f737..48eed26 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -202,28 +202,30 @@ CancellationToken cancellationToken // By this point the transformer only cares about the discovered response metadata kind. The // attribute may have originated from an MVC action attribute or from a Minimal API helper that // attached the same attribute via EndpointMetadata, but both flow through the same schema builder. - return attribute.Kind switch + return attribute switch { - PortableOpenApiResponseKind.SuccessResponse => + PortableOpenApiSuccessResponseAttributeBase successAttribute => await CreateSuccessResponseSchemaAsync( document, context, apiDescription, operation, - attribute, + successAttribute, cancellationToken ), - PortableOpenApiResponseKind.Problem or PortableOpenApiResponseKind.ValidationProblem => + PortableOpenApiErrorResponseAttributeBase errorAttribute => await CreateErrorResponseSchemaAsync( document, context, openApiVersion, apiDescription, operation, - attribute, + errorAttribute, cancellationToken ), - _ => throw new InvalidOperationException($"The response kind '{attribute.Kind}' is not supported.") + _ => throw new InvalidOperationException( + $"The response attribute '{attribute.GetType().FullName}' does not expose supported OpenAPI response metadata." + ) }; } @@ -232,19 +234,12 @@ private async Task CreateSuccessResponseSchemaAsync( OpenApiDocumentTransformerContext context, ApiDescription apiDescription, OpenApiOperation operation, - PortableOpenApiResponseAttributeBase attribute, + PortableOpenApiSuccessResponseAttributeBase attribute, CancellationToken cancellationToken ) { - if (attribute is not IPortableSuccessResponseOpenApiAttribute successAttribute) - { - throw new InvalidOperationException( - $"The response attribute '{attribute.GetType().FullName}' does not expose success-response metadata." - ); - } - - var metadataSerializationMode = successAttribute.HasMetadataSerializationModeOverride ? - successAttribute.MetadataSerializationMode : + var metadataSerializationMode = attribute.HasMetadataSerializationModeOverride ? + attribute.MetadataSerializationMode : _writeOptions.Value.MetadataSerializationMode; if (attribute.TopLevelMetadataType is not null && metadataSerializationMode == MetadataSerializationMode.ErrorsOnly) @@ -255,7 +250,7 @@ CancellationToken cancellationToken } var valueSchema = await context.GetOrCreateSchemaAsync( - successAttribute.ValueType, + attribute.ValueType, parameterDescription: null, cancellationToken ); @@ -302,12 +297,11 @@ private async Task CreateErrorResponseSchemaAsync( OpenApiSpecVersion openApiVersion, ApiDescription apiDescription, OpenApiOperation operation, - PortableOpenApiResponseAttributeBase attribute, + PortableOpenApiErrorResponseAttributeBase attribute, CancellationToken cancellationToken ) { - var errorAttribute = (PortableOpenApiErrorResponseAttributeBase) attribute; - ValidateInlineMetadataArrays(errorAttribute); + ValidateInlineMetadataArrays(attribute); var canonicalSchemaId = ResolveCanonicalErrorEnvelopeSchemaId(attribute); var itemBaseSchemaId = ResolveErrorItemSchemaId(canonicalSchemaId); @@ -317,7 +311,7 @@ CancellationToken cancellationToken openApiVersion, apiDescription, operation, - errorAttribute, + attribute, itemBaseSchemaId, cancellationToken ); diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs index 455cf86..8d75e8b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs @@ -8,11 +8,11 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// public sealed class PortableSuccessResponseOpenApiBuilder { - private readonly PortableOpenApiResponseAttributeBase _attribute; + private readonly PortableOpenApiSuccessResponseAttributeBase _attribute; private readonly Action _setMetadataSerializationMode; internal PortableSuccessResponseOpenApiBuilder( - PortableOpenApiResponseAttributeBase attribute, + PortableOpenApiSuccessResponseAttributeBase attribute, Action setMetadataSerializationMode ) { diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs index 2750cfb..7787e18 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs @@ -1,5 +1,4 @@ using System; -using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Http; namespace Light.PortableResults.AspNetCore.OpenApi; @@ -9,8 +8,7 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// /// The response value type. [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] -public class ProducesPortableSuccessResponseAttribute - : PortableOpenApiResponseAttributeBase, IPortableSuccessResponseOpenApiAttribute +public class ProducesPortableSuccessResponseAttribute : PortableOpenApiSuccessResponseAttributeBase { /// /// Initializes a new instance of . @@ -20,31 +18,5 @@ public class ProducesPortableSuccessResponseAttribute public ProducesPortableSuccessResponseAttribute( int statusCode = StatusCodes.Status200OK, string contentType = "application/json" - ) : base(PortableOpenApiResponseKind.SuccessResponse, statusCode, contentType) - { - ValueType = typeof(TValue); - } - - /// - /// Gets the response value type. - /// - public Type ValueType { get; } - - /// - /// Gets or sets the optional documentation-only override for the metadata serialization mode. - /// - public MetadataSerializationMode MetadataSerializationMode - { - get; - set - { - field = value; - HasMetadataSerializationModeOverride = true; - } - } - - bool IPortableSuccessResponseOpenApiAttribute.HasMetadataSerializationModeOverride => - HasMetadataSerializationModeOverride; - - private bool HasMetadataSerializationModeOverride { get; set; } + ) : base(statusCode, contentType, typeof(TValue)) { } } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 59306f6..d712dfd 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -153,6 +153,16 @@ public async Task MvcDocument_ShouldHonorPortableOpenApiAttributes() "application/problem+json" ).Should().BeOfType().Subject; GetSchemaReferenceId(validationSchema).Should().StartWith("PortableRichValidationProblemDetails__"); + + var customSuccessSchema = GetResponseSchema( + document, + "/mvc/openapi/custom-success", + HttpMethod.Get, + StatusCodes.Status202Accepted, + "application/json" + ).Should().BeOfType().Subject; + var customSuccessComponent = GetSchemaComponent(document, GetSchemaReferenceId(customSuccessSchema)); + customSuccessComponent.Properties.Should().ContainKeys("value", "metadata"); } [Fact] @@ -392,6 +402,20 @@ public sealed class OpenApiMvcController : ControllerBase ErrorCodes = new[] { "VersionMismatch" } )] public IActionResult GetValidation() => Problem(); + + [HttpGet("custom-success")] + [CustomPortableSuccessResponse(typeof(SuccessMetadata), StatusCodes.Status202Accepted)] + public ActionResult GetCustomSuccess() => Accepted(new MovieDto()); +} + +public sealed class CustomPortableSuccessResponseAttribute : PortableOpenApiSuccessResponseAttributeBase +{ + public CustomPortableSuccessResponseAttribute(Type metadataType, int statusCode = StatusCodes.Status200OK) + : base(statusCode, "application/json", typeof(MovieDto)) + { + TopLevelMetadataType = metadataType; + MetadataSerializationMode = MetadataSerializationMode.Always; + } } public sealed class MovieDto From c1a0a7adfe0f112cbc5ee40fdbd5e4471e767175 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 09:24:38 +0200 Subject: [PATCH 23/67] chore: add addtional comments to PortableResultsOpenApiDocumentTransformer Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiDocumentTransformer.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index 48eed26..1f53873 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -381,10 +381,14 @@ CancellationToken cancellationToken CancellationToken cancellationToken ) { + // Collect the narrowed variants we can document for this error item schema, while tracking the + // raw error codes we have already seen so we can reject conflicting metadata contracts. var documentedVariants = new List(); var rawCodeTypes = new Dictionary(StringComparer.Ordinal); var inlineSanitizedCodes = new Dictionary(StringComparer.Ordinal); + // Global error codes reuse pre-registered component schemas created from the application's + // error-contract registry, so here we only validate the code and reference the stable component id. foreach (var code in attribute.ErrorCodes ?? []) { if (!_errorMetadataContractRegistry.Contracts.TryGetValue(code, out var metadataType)) @@ -406,6 +410,9 @@ CancellationToken cancellationToken var inlineTypes = attribute.InlineErrorMetadataTypes; if (inlineCodes is not null && inlineTypes is not null) { + // Inline contracts are declared directly on the endpoint, so this branch has to create + // endpoint-specific schemas and guard against both raw-code duplicates and sanitized-name + // collisions that would otherwise map different codes onto the same component schema id. for (var i = 0; i < inlineCodes.Length; i++) { var code = inlineCodes[i]; @@ -468,6 +475,8 @@ CancellationToken cancellationToken return null; } + // The final item schema is a discriminated anyOf over all documented code-specific variants plus + // the generic fallback schema so undocumented error codes are still represented correctly. var fallbackSchema = PortableResultsOpenApiSchemas.CreateSchemaReference(document, itemBaseSchemaId); var anyOfSchemas = new List(documentedVariants.Count + 1); anyOfSchemas.AddRange(documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference)); @@ -540,7 +549,7 @@ OpenApiSpecVersion openApiVersion new OpenApiSchema { Type = JsonSchemaType.String, - Enum = [JsonValue.Create(errorCode)!] + Enum = [JsonValue.Create(errorCode)] }; return new OpenApiSchema From c5c91afe48059f25cf64e15398061f6d0bc149fb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 09:28:14 +0200 Subject: [PATCH 24/67] chore: use TryAdd in AddComponentAndCreateReference Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiDocumentTransformer.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index 1f53873..f19bf5b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -590,10 +590,7 @@ OpenApiSchema schema ) { var schemas = EnsureSchemaStore(document); - if (!schemas.ContainsKey(schemaId)) - { - schemas.Add(schemaId, schema); - } + schemas.TryAdd(schemaId, schema); return PortableResultsOpenApiSchemas.CreateSchemaReference(document, schemaId); } From 59d3825c93d264f6095edab68c06daa5d3c4deef Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 10:14:58 +0200 Subject: [PATCH 25/67] chore: remove unused parameter from AddIfMissing method in PortableResultsOpenApiSchemas Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiSchemas.cs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs index 88376f0..644ce64 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs @@ -51,23 +51,20 @@ public static void InstallInto(OpenApiDocument document) ArgumentNullException.ThrowIfNull(document); var schemas = EnsureSchemaStore(document); - AddIfMissing(document, schemas, ErrorCategorySchemaId, CreateErrorCategorySchema()); - AddIfMissing(document, schemas, PortableErrorSchemaId, CreatePortableErrorSchema(document)); + AddIfMissing(schemas, ErrorCategorySchemaId, CreateErrorCategorySchema()); + AddIfMissing(schemas, PortableErrorSchemaId, CreatePortableErrorSchema(document)); AddIfMissing( - document, schemas, PortableValidationErrorDetailSchemaId, CreatePortableValidationErrorDetailSchema(document) ); - AddIfMissing(document, schemas, PortableProblemDetailsSchemaId, CreatePortableProblemDetailsSchema(document)); + AddIfMissing(schemas, PortableProblemDetailsSchemaId, CreatePortableProblemDetailsSchema(document)); AddIfMissing( - document, schemas, PortableRichValidationProblemDetailsSchemaId, CreatePortableRichValidationProblemDetailsSchema(document) ); AddIfMissing( - document, schemas, PortableAspNetCoreValidationProblemDetailsSchemaId, CreatePortableAspNetCoreValidationProblemDetailsSchema(document) @@ -106,16 +103,12 @@ private static IDictionary EnsureSchemaStore(OpenApiDocu } private static void AddIfMissing( - OpenApiDocument document, IDictionary schemas, string schemaId, OpenApiSchema schema ) { - if (!schemas.ContainsKey(schemaId)) - { - schemas.Add(schemaId, schema); - } + schemas.TryAdd(schemaId, schema); } private static OpenApiSchema CreateErrorCategorySchema() @@ -124,7 +117,7 @@ private static OpenApiSchema CreateErrorCategorySchema() { Type = JsonSchemaType.String, Enum = Enum.GetNames(typeof(ErrorCategory)) - .Select(static name => (JsonNode) JsonValue.Create(name)!) + .Select(static name => (JsonNode) JsonValue.Create(name)) .ToList() }; } From dd9254a8c2e589a83d108045bfe44db2d903480e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 10:15:23 +0200 Subject: [PATCH 26/67] chore: inject PortableResultsHttpWriteOptions directly into PortableResultsOpenApiDocumentTransformer Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiDocumentTransformer.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index f19bf5b..830ea6f 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -23,18 +23,19 @@ namespace Light.PortableResults.AspNetCore.OpenApi; public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocumentTransformer { private readonly IPortableErrorMetadataContractRegistry _errorMetadataContractRegistry; - private readonly IOptions _writeOptions; + private readonly PortableResultsHttpWriteOptions _writeOptions; /// /// Initializes a new instance of . /// public PortableResultsOpenApiDocumentTransformer( - IOptions writeOptions, + PortableResultsHttpWriteOptions writeOptions, IPortableErrorMetadataContractRegistry errorMetadataContractRegistry ) { - _writeOptions = writeOptions; - _errorMetadataContractRegistry = errorMetadataContractRegistry; + _writeOptions = writeOptions ?? throw new ArgumentNullException(nameof(writeOptions)); + _errorMetadataContractRegistry = errorMetadataContractRegistry ?? + throw new ArgumentNullException(nameof(errorMetadataContractRegistry)); } /// @@ -240,7 +241,7 @@ CancellationToken cancellationToken { var metadataSerializationMode = attribute.HasMetadataSerializationModeOverride ? attribute.MetadataSerializationMode : - _writeOptions.Value.MetadataSerializationMode; + _writeOptions.MetadataSerializationMode; if (attribute.TopLevelMetadataType is not null && metadataSerializationMode == MetadataSerializationMode.ErrorsOnly) { @@ -663,7 +664,7 @@ private string ResolveCanonicalErrorEnvelopeSchemaId(PortableOpenApiResponseAttr var validationAttribute = (ProducesPortableValidationProblemAttribute) attribute; var format = validationAttribute.HasFormatOverride ? validationAttribute.Format : - _writeOptions.Value.ValidationProblemSerializationFormat; + _writeOptions.ValidationProblemSerializationFormat; return format == ValidationProblemSerializationFormat.AspNetCoreCompatible ? PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId : PortableResultsOpenApiSchemas.PortableRichValidationProblemDetailsSchemaId; From 49b37c236a51a79eae473b0765ae147bbef0465a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 11:12:27 +0200 Subject: [PATCH 27/67] fix: ValidateInlineMetadataArrays now throws when one of the arrays is missing Signed-off-by: Kenny Pflug --- ...rtableResultsOpenApiDocumentTransformer.cs | 9 +- .../PortableResultsOpenApiMessages.cs | 3 + ...eResultsOpenApiDocumentTransformerTests.cs | 83 ++++++++++++++----- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index 830ea6f..b27b771 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -680,11 +680,18 @@ private static string ResolveErrorItemSchemaId(string canonicalEnvelopeSchemaId) private static void ValidateInlineMetadataArrays(PortableOpenApiErrorResponseAttributeBase attribute) { - if (attribute.InlineErrorMetadataCodes is null || attribute.InlineErrorMetadataTypes is null) + if (attribute.InlineErrorMetadataCodes is null && attribute.InlineErrorMetadataTypes is null) { return; } + if (attribute.InlineErrorMetadataCodes is null || attribute.InlineErrorMetadataTypes is null) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateIncompleteInlineErrorMetadataMessage() + ); + } + if (attribute.InlineErrorMetadataCodes.Length == attribute.InlineErrorMetadataTypes.Length) { return; diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs index f61fdbb..a4b0dfd 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs @@ -20,4 +20,7 @@ string sanitizedCode internal static string CreateUnknownErrorCodeMessage(string code) => $"The error code '{code}' is not registered in ConfigureErrorMetadataContracts. Register it globally or use WithErrorMetadata as an inline escape hatch."; + + internal static string CreateIncompleteInlineErrorMetadataMessage() => + "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataTypes together."; } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index d712dfd..0ec4cf1 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -1,20 +1,17 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using Light.PortableResults.AspNetCore.MinimalApis; using Light.PortableResults.AspNetCore.Mvc; -using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Http.Writing; using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; using Xunit; @@ -67,8 +64,10 @@ public async Task MinimalApiDocument_ShouldEmitConfiguredSchemas() var globalProblemErrors = (OpenApiSchema) globalProblemExtension.Properties!["errors"]!; var globalProblemItems = (OpenApiSchema) globalProblemErrors.Items!; globalProblemItems.AnyOf.Should().HaveCount(3); - GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf![0]).Should().Be("PortableError__VersionMismatch"); - GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[1]).Should().Be("PortableError__Insufficient_Funds"); + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf![0]).Should() + .Be("PortableError__VersionMismatch"); + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[1]).Should() + .Be("PortableError__Insufficient_Funds"); GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[2]).Should().Be("PortableError"); globalProblemItems.Discriminator.Should().NotBeNull(); globalProblemItems.Discriminator!.PropertyName.Should().Be("code"); @@ -133,7 +132,8 @@ public async Task MvcDocument_ShouldHonorPortableOpenApiAttributes() StatusCodes.Status200OK, "application/json" ).Should().BeOfType().Subject; - GetSchemaComponent(document, GetSchemaReferenceId(successSchema)).Properties.Should().ContainKeys("value", "metadata"); + GetSchemaComponent(document, GetSchemaReferenceId(successSchema)).Properties.Should() + .ContainKeys("value", "metadata"); var problemSchema = GetResponseSchema( document, @@ -168,7 +168,8 @@ public async Task MvcDocument_ShouldHonorPortableOpenApiAttributes() [Fact] public async Task Transformer_ShouldUseEnumNarrowingForOpenApi30AndConstForOpenApi31() { - await using var openApi30App = CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0); + await using var openApi30App = + CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0); var openApi30Document = await GetOpenApiDocumentAsync(openApi30App); var openApi30Variant = GetSchemaComponent(openApi30Document, "PortableError__VersionMismatch"); @@ -177,7 +178,8 @@ public async Task Transformer_ShouldUseEnumNarrowingForOpenApi30AndConstForOpenA openApi30CodeSchema.Enum.Should().ContainSingle(); openApi30CodeSchema.Enum![0]!.ToJsonString().Should().Be("\"VersionMismatch\""); - await using var openApi31App = CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1); + await using var openApi31App = + CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1); var openApi31Document = await GetOpenApiDocumentAsync(openApi31App); var openApi31Variant = GetSchemaComponent(openApi31Document, "PortableError__VersionMismatch"); @@ -200,8 +202,8 @@ public async Task Transformer_ShouldThrowWhenAnEndpointUsesAnUnknownGlobalErrorC var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() - .ThrowAsync() - .WithMessage("*UnknownCode*ConfigureErrorMetadataContracts*WithErrorMetadata*"); + .ThrowAsync() + .WithMessage("*UnknownCode*ConfigureErrorMetadataContracts*WithErrorMetadata*"); } [Fact] @@ -219,8 +221,8 @@ public async Task Transformer_ShouldThrowWhenSuccessMetadataIsDocumentedForError var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() - .ThrowAsync() - .WithMessage("*MetadataSerializationMode is ErrorsOnly*"); + .ThrowAsync() + .WithMessage("*MetadataSerializationMode is ErrorsOnly*"); } [Fact] @@ -238,9 +240,43 @@ public async Task Transformer_ShouldThrowWhenDuplicateKindsShareTheSameResponseK var act = async () => await GetOpenApiDocumentAsync(app); + await act + .Should() + .ThrowAsync() + .WithMessage("*status code 400*kind 'Problem'*"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Transformer_ShouldThrowWhenInlineErrorMetadataArraysAreOnlyPartiallyConfigured( + bool configureCodes + ) + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + var attribute = new ProducesPortableProblemAttribute(StatusCodes.Status404NotFound); + if (configureCodes) + { + attribute.InlineErrorMetadataCodes = ["Movie/Gone"]; + } + else + { + attribute.InlineErrorMetadataTypes = [typeof(InlineProblemMetadata)]; + } + + webApplication + .MapGet($"/minimal/problems/invalid-inline-{configureCodes}", static () => TypedResults.Problem()) + .WithMetadata(attribute); + } + ); + + var act = async () => await GetOpenApiDocumentAsync(app); + await act.Should() - .ThrowAsync() - .WithMessage("*status code 400*kind 'Problem'*"); + .ThrowAsync() + .WithMessage("*InlineErrorMetadataCodes*InlineErrorMetadataTypes*"); } [Fact] @@ -274,7 +310,8 @@ private static WebApplication CreateMinimalApiApp( options => { options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; - options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.AspNetCoreCompatible; + options.ValidationProblemSerializationFormat = + ValidationProblemSerializationFormat.AspNetCoreCompatible; } ); builder.Services.ConfigureErrorMetadataContracts( @@ -293,21 +330,21 @@ private static WebApplication CreateMinimalApiApp( .ProducesPortableSuccessResponse( configure: x => x.WithMetadata() - .UseMetadataSerializationMode(MetadataSerializationMode.Always) + .UseMetadataSerializationMode(MetadataSerializationMode.Always) ); app.MapGet("/minimal/problems/global", static () => TypedResults.Problem()) .ProducesPortableProblem( StatusCodes.Status409Conflict, configure: x => x.WithMetadata() - .WithErrorCodes("VersionMismatch", "Insufficient/Funds") + .WithErrorCodes("VersionMismatch", "Insufficient/Funds") ); app.MapGet("/minimal/problems/inline", static () => TypedResults.Problem()) .ProducesPortableProblem( StatusCodes.Status404NotFound, configure: x => x.WithMetadata() - .WithErrorMetadata("Movie/Gone") + .WithErrorMetadata("Movie/Gone") ); app.MapGet("/minimal/validation/default", static () => TypedResults.Problem()) .ProducesPortableValidationProblem(); @@ -315,11 +352,11 @@ private static WebApplication CreateMinimalApiApp( .ProducesPortableValidationProblem( configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich) - .WithErrorCodes("VersionMismatch") + .WithErrorCodes("VersionMismatch") ); app.MapGet("/minimal/problems/union", static () => TypedResults.Problem()) .ProducesPortableProblem(StatusCodes.Status400BadRequest) - .ProducesPortableValidationProblem(StatusCodes.Status400BadRequest); + .ProducesPortableValidationProblem(); configureEndpoints?.Invoke(app); return app; @@ -335,7 +372,8 @@ private static WebApplication CreateMvcApp() options => { options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; - options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.AspNetCoreCompatible; + options.ValidationProblemSerializationFormat = + ValidationProblemSerializationFormat.AspNetCoreCompatible; } ); builder.Services.ConfigureErrorMetadataContracts( @@ -374,7 +412,6 @@ private static string GetSchemaReferenceId(OpenApiSchemaReference schemaReferenc referenceId.Should().NotBeNull(); return referenceId!; } - } [ApiController] From 4d9298c0a84ded685661d028474e11664e82d8bf Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 11:15:15 +0200 Subject: [PATCH 28/67] chore: remove dead EscapeJsonPointer code Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiSchemaNaming.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs index 8499fa3..ae51e86 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs @@ -75,18 +75,6 @@ public static string CreateMetadataSchemaId(string ownerSchemaId) return $"{ownerSchemaId}__Metadata"; } - /// - /// Escapes a value for use inside a JSON Pointer segment. - /// - /// The raw segment value. - /// The escaped JSON Pointer value. - public static string EscapeJsonPointer(string value) - { - ArgumentNullException.ThrowIfNull(value); - return value.Replace("~", "~0", StringComparison.Ordinal) - .Replace("/", "~1", StringComparison.Ordinal); - } - /// /// Sanitizes an error code so it can be embedded safely into an OpenAPI component schema id. /// From de6c67c3575d225e88c682efb426bcc737b3ed71 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 11:24:33 +0200 Subject: [PATCH 29/67] fix: metadata can now be null Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiSchemas.cs | 2 +- ...eResultsOpenApiDocumentTransformerTests.cs | 36 +++++++++++-------- .../PortableResultsOpenApiSchemasTests.cs | 6 ++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs index 644ce64..807e4e7 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs @@ -79,7 +79,7 @@ public static OpenApiSchema CreateOpenMetadataSchema() { return new OpenApiSchema { - Type = JsonSchemaType.Object, + Type = JsonSchemaType.Object | JsonSchemaType.Null, AdditionalPropertiesAllowed = true }; } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 0ec4cf1..d61f978 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -61,7 +61,7 @@ public async Task MinimalApiDocument_ShouldEmitConfiguredSchemas() ).Should().BeOfType().Subject; var globalProblemComponent = GetSchemaComponent(document, GetSchemaReferenceId(globalProblemSchema)); var globalProblemExtension = (OpenApiSchema) globalProblemComponent.AllOf![1]; - var globalProblemErrors = (OpenApiSchema) globalProblemExtension.Properties!["errors"]!; + var globalProblemErrors = (OpenApiSchema) globalProblemExtension.Properties!["errors"]; var globalProblemItems = (OpenApiSchema) globalProblemErrors.Items!; globalProblemItems.AnyOf.Should().HaveCount(3); GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf![0]).Should() @@ -85,7 +85,7 @@ public async Task MinimalApiDocument_ShouldEmitConfiguredSchemas() ).Should().BeOfType().Subject; var inlineProblemComponent = GetSchemaComponent(document, GetSchemaReferenceId(inlineProblemSchema)); var inlineProblemExtension = (OpenApiSchema) inlineProblemComponent.AllOf![1]; - var inlineProblemItems = (OpenApiSchema) ((OpenApiSchema) inlineProblemExtension.Properties!["errors"]!).Items!; + var inlineProblemItems = (OpenApiSchema) ((OpenApiSchema) inlineProblemExtension.Properties!["errors"]).Items!; GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf![0]).Should().Contain("PortableError__"); GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf[0]).Should().Contain("Movie_Gone"); GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf[1]).Should().Be("PortableError"); @@ -173,17 +173,17 @@ public async Task Transformer_ShouldUseEnumNarrowingForOpenApi30AndConstForOpenA var openApi30Document = await GetOpenApiDocumentAsync(openApi30App); var openApi30Variant = GetSchemaComponent(openApi30Document, "PortableError__VersionMismatch"); - var openApi30CodeSchema = (OpenApiSchema) ((OpenApiSchema) openApi30Variant.AllOf![1]).Properties!["code"]!; + var openApi30CodeSchema = (OpenApiSchema) ((OpenApiSchema) openApi30Variant.AllOf![1]).Properties!["code"]; openApi30CodeSchema.Const.Should().BeNull(); openApi30CodeSchema.Enum.Should().ContainSingle(); - openApi30CodeSchema.Enum![0]!.ToJsonString().Should().Be("\"VersionMismatch\""); + openApi30CodeSchema.Enum![0].ToJsonString().Should().Be("\"VersionMismatch\""); await using var openApi31App = CreateMinimalApiApp(options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1); var openApi31Document = await GetOpenApiDocumentAsync(openApi31App); var openApi31Variant = GetSchemaComponent(openApi31Document, "PortableError__VersionMismatch"); - var openApi31CodeSchema = (OpenApiSchema) ((OpenApiSchema) openApi31Variant.AllOf![1]).Properties!["code"]!; + var openApi31CodeSchema = (OpenApiSchema) ((OpenApiSchema) openApi31Variant.AllOf![1]).Properties!["code"]; openApi31CodeSchema.Const.Should().Be("VersionMismatch"); } @@ -199,6 +199,7 @@ public async Task Transformer_ShouldThrowWhenAnEndpointUsesAnUnknownGlobalErrorC } ); + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() @@ -218,6 +219,7 @@ public async Task Transformer_ShouldThrowWhenSuccessMetadataIsDocumentedForError } ); + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() @@ -238,10 +240,10 @@ public async Task Transformer_ShouldThrowWhenDuplicateKindsShareTheSameResponseK } ); + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal var act = async () => await GetOpenApiDocumentAsync(app); - await act - .Should() + await act.Should() .ThrowAsync() .WithMessage("*status code 400*kind 'Problem'*"); } @@ -259,11 +261,11 @@ bool configureCodes var attribute = new ProducesPortableProblemAttribute(StatusCodes.Status404NotFound); if (configureCodes) { - attribute.InlineErrorMetadataCodes = ["Movie/Gone"]; + attribute.InlineErrorMetadataCodes = new[] { "Movie/Gone" }; } else { - attribute.InlineErrorMetadataTypes = [typeof(InlineProblemMetadata)]; + attribute.InlineErrorMetadataTypes = new[] { typeof(InlineProblemMetadata) }; } webApplication @@ -272,6 +274,7 @@ bool configureCodes } ); + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() @@ -395,7 +398,7 @@ private static IOpenApiSchema GetResponseSchema( string contentType ) { - var pathItem = document.Paths![path]; + var pathItem = document.Paths[path]; var operation = pathItem.Operations![httpMethod]; var response = (OpenApiResponse) operation.Responses![statusCode.ToString(CultureInfo.InvariantCulture)]; return response.Content![contentType].Schema!; @@ -408,9 +411,9 @@ private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string private static string GetSchemaReferenceId(OpenApiSchemaReference schemaReference) { - var referenceId = schemaReference.Reference?.Id ?? schemaReference.Id; + var referenceId = schemaReference.Reference.Id ?? schemaReference.Id; referenceId.Should().NotBeNull(); - return referenceId!; + return referenceId; } } @@ -429,14 +432,14 @@ public sealed class OpenApiMvcController : ControllerBase [ProducesPortableProblem( StatusCodes.Status404NotFound, TopLevelMetadataType = typeof(ProblemMetadata), - ErrorCodes = new[] { "VersionMismatch" } + ErrorCodes = ["VersionMismatch"] )] public IActionResult GetProblem() => Problem(); [HttpGet("validation")] [ProducesPortableValidationProblem( Format = ValidationProblemSerializationFormat.Rich, - ErrorCodes = new[] { "VersionMismatch" } + ErrorCodes = ["VersionMismatch"] )] public IActionResult GetValidation() => Problem(); @@ -455,6 +458,7 @@ public CustomPortableSuccessResponseAttribute(Type metadataType, int statusCode } } +// ReSharper disable UnusedMember.Global - required for testing public sealed class MovieDto { public string Title { get; init; } = string.Empty; @@ -475,12 +479,16 @@ public sealed class InlineProblemMetadata public string MovieId { get; init; } = string.Empty; } +// ReSharper disable once ClassNeverInstantiated.Global public sealed class VersionMismatchMetadata { public string CurrentVersion { get; init; } = string.Empty; } +// ReSharper disable once ClassNeverInstantiated.Global public sealed class FundsMetadata { public decimal MissingAmount { get; init; } } +// ReSharper restore UnusedMember.Global + diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs index de123f8..e8bb0e3 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs @@ -27,5 +27,11 @@ public void InstallInto_ShouldAddTheCanonicalSchemaCatalog() "PortableAspNetCoreValidationProblemDetails" } ); + + var portableError = + (OpenApiSchema) document.Components.Schemas[PortableResultsOpenApiSchemas.PortableErrorSchemaId]; + var metadataSchema = (OpenApiSchema) portableError.Properties!["metadata"]; + metadataSchema.Type.Should().Be(JsonSchemaType.Object | JsonSchemaType.Null); + metadataSchema.AdditionalPropertiesAllowed.Should().BeTrue(); } } From cb781767a8eccb0352de33d07a3b0c5c8a88aad4 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 11:26:34 +0200 Subject: [PATCH 30/67] chore: SanitizeSegment is now private Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiSchemaNaming.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs index ae51e86..6da18f8 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs @@ -91,7 +91,7 @@ public static string SanitizeErrorCode(string value) /// /// The raw segment value. /// The sanitized segment. - public static string SanitizeSegment(string value) + private static string SanitizeSegment(string value) { ArgumentNullException.ThrowIfNull(value); From cfff5d8c8a835a435ca86885eaa3928ed9b7c7fc Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 11:39:18 +0200 Subject: [PATCH 31/67] fix: ResponseGroupKey is now case-insensitive Signed-off-by: Kenny Pflug --- ...rtableResultsOpenApiDocumentTransformer.cs | 69 +++++++++++++++-- ...eResultsOpenApiDocumentTransformerTests.cs | 76 ++++++++++++++++++- 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index b27b771..75f469b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -180,13 +180,16 @@ CancellationToken cancellationToken } var response = GetOrCreateResponse(operation, responseGroup.Key.StatusCode); - response.Content ??= new Dictionary(StringComparer.Ordinal); - response.Content[responseGroup.Key.ContentType] = new OpenApiMediaType - { - Schema = contributingSchemas.Count == 1 ? - contributingSchemas[0] : - new OpenApiSchema { AnyOf = contributingSchemas } - }; + SetResponseContent( + response, + responseGroup.Key.ContentType, + new OpenApiMediaType + { + Schema = contributingSchemas.Count == 1 ? + contributingSchemas[0] : + new OpenApiSchema { AnyOf = contributingSchemas } + } + ); } } @@ -654,6 +657,41 @@ private static OpenApiResponse GetOrCreateResponse(OpenApiOperation operation, i return (OpenApiResponse) response; } + private static void SetResponseContent( + OpenApiResponse response, + string contentType, + OpenApiMediaType mediaType + ) + { + if (response.Content is null) + { + response.Content = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [contentType] = mediaType + }; + return; + } + + var existingContentType = FindExistingContentType(response.Content, contentType); + response.Content[existingContentType ?? contentType] = mediaType; + } + + private static string? FindExistingContentType( + IDictionary content, + string contentType + ) + { + foreach (var existingContentType in content.Keys) + { + if (string.Equals(existingContentType, contentType, StringComparison.OrdinalIgnoreCase)) + { + return existingContentType; + } + } + + return null; + } + private string ResolveCanonicalErrorEnvelopeSchemaId(PortableOpenApiResponseAttributeBase attribute) { if (attribute.Kind == PortableOpenApiResponseKind.Problem) @@ -760,7 +798,22 @@ private static string NormalizeRouteSegment(string segment) return "{" + content[..constraintSeparatorIndex] + "}"; } - private readonly record struct ResponseGroupKey(int StatusCode, string ContentType); + private readonly record struct ResponseGroupKey(int StatusCode, string ContentType) + { + public bool Equals(ResponseGroupKey other) + { + return StatusCode == other.StatusCode && + string.Equals(ContentType, other.ContentType, StringComparison.OrdinalIgnoreCase); + } + + public override int GetHashCode() + { + return HashCode.Combine( + StatusCode, + ContentType is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(ContentType) + ); + } + } private readonly record struct DocumentedErrorVariant( string RawCode, diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index d61f978..04b8997 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -248,6 +248,68 @@ await act.Should() .WithMessage("*status code 400*kind 'Problem'*"); } + [Fact] + public async Task Transformer_ShouldTreatDuplicateKindsWithDifferentContentTypeCasingAsAmbiguous() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/ambiguous-casing", static () => TypedResults.Problem()) + .WithMetadata(new ProducesPortableProblemAttribute( + StatusCodes.Status400BadRequest, + "application/problem+json" + )) + .WithMetadata(new ProducesPortableProblemAttribute( + StatusCodes.Status400BadRequest, + "application/PROBLEM+JSON" + )); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .WithMessage("*status code 400*application/problem+json*kind 'Problem'*"); + } + + [Fact] + public async Task Transformer_ShouldMergeResponseSchemas_WhenContentTypeOnlyDiffersByCasing() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/union-casing", static () => TypedResults.Problem()) + .WithMetadata(new ProducesPortableProblemAttribute( + StatusCodes.Status400BadRequest, + "application/problem+json" + )) + .WithMetadata(new ProducesPortableValidationProblemAttribute( + StatusCodes.Status400BadRequest, + "application/PROBLEM+JSON" + )); + } + ); + + var response = GetResponse( + await GetOpenApiDocumentAsync(app), + "/minimal/problems/union-casing", + HttpMethod.Get, + StatusCodes.Status400BadRequest + ); + + response.Content.Should().ContainSingle(); + response.Content!.Keys.Should().ContainSingle( + key => string.Equals(key, "application/problem+json", StringComparison.OrdinalIgnoreCase) + ); + var mediaType = response.Content.Values.Should().ContainSingle().Subject; + mediaType.Schema.Should().BeOfType(); + ((OpenApiSchema) mediaType.Schema!).AnyOf.Should().HaveCount(2); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -397,11 +459,21 @@ private static IOpenApiSchema GetResponseSchema( int statusCode, string contentType ) + { + var response = GetResponse(document, path, httpMethod, statusCode); + return response.Content![contentType].Schema!; + } + + private static OpenApiResponse GetResponse( + OpenApiDocument document, + string path, + HttpMethod httpMethod, + int statusCode + ) { var pathItem = document.Paths[path]; var operation = pathItem.Operations![httpMethod]; - var response = (OpenApiResponse) operation.Responses![statusCode.ToString(CultureInfo.InvariantCulture)]; - return response.Content![contentType].Schema!; + return (OpenApiResponse) operation.Responses![statusCode.ToString(CultureInfo.InvariantCulture)]; } private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) From 72647e146cd9b4d03f2c1b4746cfe8f40c39dae9 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 13:53:14 +0200 Subject: [PATCH 32/67] fix: OpenAPI GetOrCreateResponse now can handle referenced responses Signed-off-by: Kenny Pflug --- ...rtableResultsOpenApiDocumentTransformer.cs | 31 ++++++++++++-- ...eResultsOpenApiDocumentTransformerTests.cs | 40 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index 75f469b..f74724d 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -647,14 +647,39 @@ private static OpenApiResponse GetOrCreateResponse(OpenApiOperation operation, i var responseKey = statusCode.ToString(CultureInfo.InvariantCulture); if (!operation.Responses.TryGetValue(responseKey, out var response)) { - response = new OpenApiResponse + var createdResponse = new OpenApiResponse { Description = $"HTTP {statusCode}" }; - operation.Responses.Add(responseKey, response); + operation.Responses.Add(responseKey, createdResponse); + return createdResponse; } - return (OpenApiResponse) response; + if (response is OpenApiResponse concreteResponse) + { + return concreteResponse; + } + + if (response is not OpenApiResponseReference responseReference) + { + throw new InvalidOperationException( + $"The OpenAPI response entry for status code {statusCode} must be either an OpenApiResponse or an OpenApiResponseReference, but was '{response.GetType().FullName}'." + ); + } + + var materializedResponse = new OpenApiResponse + { + Description = string.IsNullOrWhiteSpace(responseReference.Description) ? + $"HTTP {statusCode}" : + responseReference.Description, + Content = responseReference.Content, + Headers = responseReference.Headers, + Links = responseReference.Links, + Extensions = responseReference.Extensions + }; + + operation.Responses[responseKey] = materializedResponse; + return materializedResponse; } private static void SetResponseContent( diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 04b8997..e384e7e 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -310,6 +310,46 @@ await GetOpenApiDocumentAsync(app), ((OpenApiSchema) mediaType.Schema!).AnyOf.Should().HaveCount(2); } + [Fact] + public async Task Transformer_ShouldMaterializeReferencedResponsesBeforeWritingContent() + { + await using var app = CreateMinimalApiApp( + configureOpenApi: options => + { + options.AddOperationTransformer( + (operation, _, _) => + { + operation.Responses ??= new OpenApiResponses(); + operation.Responses[StatusCodes.Status409Conflict.ToString(CultureInfo.InvariantCulture)] = + new OpenApiResponseReference( + "UpstreamConflictResponse", + new OpenApiDocument(), + externalResource: null + ); + return Task.CompletedTask; + } + ); + }, + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/upstream-reference", static () => TypedResults.Problem()) + .ProducesPortableProblem(StatusCodes.Status409Conflict); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var responseEntry = document + .Paths["/minimal/problems/upstream-reference"] + .Operations![HttpMethod.Get] + .Responses![StatusCodes.Status409Conflict.ToString(CultureInfo.InvariantCulture)]; + + responseEntry.Should().BeOfType(); + var response = (OpenApiResponse) responseEntry; + response.Content.Should().ContainKey("application/problem+json"); + response.Content["application/problem+json"].Schema.Should().BeOfType(); + } + [Theory] [InlineData(true)] [InlineData(false)] From e10d64fee6bc0f89ed6ceb0beec5b62248221841 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 14:06:08 +0200 Subject: [PATCH 33/67] chore: document that PortableResults OpenAPI metadata is authoritative, i.e., it replaces existing schemas with the same key Signed-off-by: Kenny Pflug --- README.md | 2 + ...ltsOpenApiRouteHandlerBuilderExtensions.cs | 15 +++++ .../ProducesPortableProblemAttribute.cs | 5 ++ ...roducesPortableSuccessResponseAttribute.cs | 5 ++ ...ducesPortableValidationProblemAttribute.cs | 5 ++ ...eResultsOpenApiDocumentTransformerTests.cs | 59 +++++++++++++++++++ 6 files changed, 91 insertions(+) diff --git a/README.md b/README.md index 2256cd6..6db91bf 100644 --- a/README.md +++ b/README.md @@ -1363,6 +1363,8 @@ Use `UseMetadataSerializationMode(...)` on Minimal APIs or the `MetadataSerializ `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`. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs index ae0dc27..4c06453 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs @@ -12,6 +12,11 @@ public static class PortableResultsOpenApiRouteHandlerBuilderExtensions /// /// Documents a Light.PortableResults success response. /// + /// + /// For a given HTTP status code and content type, PortableResults OpenAPI metadata is authoritative. + /// If another OpenAPI contributor already documented the same response slot, this helper replaces that + /// media-type schema instead of merging it. + /// public static RouteHandlerBuilder ProducesPortableSuccessResponse( this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, @@ -34,6 +39,11 @@ public static RouteHandlerBuilder ProducesPortableSuccessResponse( /// /// Documents a Light.PortableResults problem details response. /// + /// + /// For a given HTTP status code and content type, PortableResults OpenAPI metadata is authoritative. + /// If another OpenAPI contributor already documented the same response slot, this helper replaces that + /// media-type schema instead of merging it. + /// public static RouteHandlerBuilder ProducesPortableProblem( this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, @@ -51,6 +61,11 @@ public static RouteHandlerBuilder ProducesPortableProblem( /// /// Documents a Light.PortableResults validation problem details response. /// + /// + /// For a given HTTP status code and content type, PortableResults OpenAPI metadata is authoritative. + /// If another OpenAPI contributor already documented the same response slot, this helper replaces that + /// media-type schema instead of merging it. + /// public static RouteHandlerBuilder ProducesPortableValidationProblem( this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs index 38df184..6add119 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs @@ -6,6 +6,11 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// /// Documents a Light.PortableResults problem details response. /// +/// +/// For a given HTTP status code and content type, PortableResults OpenAPI metadata is authoritative. +/// If another OpenAPI contributor already documented the same response slot, this attribute replaces that +/// media-type schema instead of merging it. +/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public class ProducesPortableProblemAttribute : PortableOpenApiErrorResponseAttributeBase { diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs index 7787e18..0ce63d1 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs @@ -6,6 +6,11 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// /// Documents a Light.PortableResults success response. /// +/// +/// For a given HTTP status code and content type, PortableResults OpenAPI metadata is authoritative. +/// If another OpenAPI contributor already documented the same response slot, this attribute replaces that +/// media-type schema instead of merging it. +/// /// The response value type. [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public class ProducesPortableSuccessResponseAttribute : PortableOpenApiSuccessResponseAttributeBase diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs index 216563b..132d0eb 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs @@ -7,6 +7,11 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// /// Documents a Light.PortableResults validation problem response. /// +/// +/// For a given HTTP status code and content type, PortableResults OpenAPI metadata is authoritative. +/// If another OpenAPI contributor already documented the same response slot, this attribute replaces that +/// media-type schema instead of merging it. +/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public class ProducesPortableValidationProblemAttribute : PortableOpenApiErrorResponseAttributeBase { diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index e384e7e..ee0f204 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Threading.Tasks; @@ -258,6 +259,7 @@ public async Task Transformer_ShouldTreatDuplicateKindsWithDifferentContentTypeC .MapGet("/minimal/problems/ambiguous-casing", static () => TypedResults.Problem()) .WithMetadata(new ProducesPortableProblemAttribute( StatusCodes.Status400BadRequest, + // ReSharper disable once RedundantArgumentDefaultValue "application/problem+json" )) .WithMetadata(new ProducesPortableProblemAttribute( @@ -285,6 +287,7 @@ public async Task Transformer_ShouldMergeResponseSchemas_WhenContentTypeOnlyDiff .MapGet("/minimal/problems/union-casing", static () => TypedResults.Problem()) .WithMetadata(new ProducesPortableProblemAttribute( StatusCodes.Status400BadRequest, + // ReSharper disable once RedundantArgumentDefaultValue "application/problem+json" )) .WithMetadata(new ProducesPortableValidationProblemAttribute( @@ -350,6 +353,62 @@ public async Task Transformer_ShouldMaterializeReferencedResponsesBeforeWritingC response.Content["application/problem+json"].Schema.Should().BeOfType(); } + [Fact] + public async Task Transformer_ShouldReplaceExistingSchemaForTheSameResponseSlot() + { + await using var app = CreateMinimalApiApp( + configureOpenApi: options => + { + options.AddOperationTransformer( + (operation, context, _) => + { + if (!string.Equals(context.Description.RelativePath, "minimal/success/replaces-existing", StringComparison.Ordinal)) + { + return Task.CompletedTask; + } + + operation.Responses ??= new OpenApiResponses(); + operation.Responses[StatusCodes.Status200OK.ToString(CultureInfo.InvariantCulture)] = + new OpenApiResponse + { + Description = "HTTP 200", + Content = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["application/json"] = new() + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + }; + return Task.CompletedTask; + } + ); + }, + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/success/replaces-existing", static () => TypedResults.Ok(new MovieDto())) + .ProducesPortableSuccessResponse(); + } + ); + + var schema = GetResponseSchema( + await GetOpenApiDocumentAsync(app), + "/minimal/success/replaces-existing", + HttpMethod.Get, + StatusCodes.Status200OK, + "application/json" + ); + + schema.Should().BeOfType(); + var concreteSchema = (OpenApiSchema) schema; + concreteSchema.Type.Should().NotBe(JsonSchemaType.String); + concreteSchema.Properties.Should().ContainKey("title"); + } + [Theory] [InlineData(true)] [InlineData(false)] From 9b2482f65ba733af8f0bfefefb5481bc21c75158 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 14:14:36 +0200 Subject: [PATCH 34/67] chore: RegisterErrorMetadataContractRegistry only once Signed-off-by: Kenny Pflug --- .../PortableErrorMetadataContractRegistry.cs | 3 + .../PortableResultsOpenApiModule.cs | 14 ++-- ...eResultsOpenApiDocumentTransformerTests.cs | 79 ++++++++++++++----- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs index 49eba72..344fd78 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs @@ -38,6 +38,9 @@ public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuild } var sanitizedCode = PortableResultsOpenApiSchemaNaming.SanitizeErrorCode(code); + // ForCode already rejects sanitized-name collisions, but the registry is the final snapshot + // boundary before document generation and keeps the same guard in case the builder contents + // were composed outside that API or future option-pipeline changes bypass the builder check. if (sanitizedCodes.TryGetValue(sanitizedCode, out var existingCode)) { throw new InvalidOperationException( diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs index 8574a03..16e7bee 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -20,12 +20,7 @@ public static IServiceCollection AddPortableResultsOpenApi(this IServiceCollecti ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton(); - services.TryAddSingleton( - static serviceProvider => - new PortableErrorMetadataContractRegistry( - serviceProvider.GetRequiredService>().Value.Builder - ) - ); + RegisterErrorMetadataContractRegistry(services); if (services.Any(static descriptor => descriptor.ServiceType == typeof(PortableResultsOpenApiRegistrationGate))) { @@ -51,13 +46,18 @@ Action configure ArgumentNullException.ThrowIfNull(configure); services.Configure(options => configure(options.Builder)); + RegisterErrorMetadataContractRegistry(services); + return services; + } + + private static void RegisterErrorMetadataContractRegistry(IServiceCollection services) + { services.TryAddSingleton( static serviceProvider => new PortableErrorMetadataContractRegistry( serviceProvider.GetRequiredService>().Value.Builder ) ); - return services; } private sealed class PortableResultsOpenApiRegistrationGate; diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index ee0f204..204c7d7 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; @@ -257,15 +258,19 @@ public async Task Transformer_ShouldTreatDuplicateKindsWithDifferentContentTypeC { webApplication .MapGet("/minimal/problems/ambiguous-casing", static () => TypedResults.Problem()) - .WithMetadata(new ProducesPortableProblemAttribute( - StatusCodes.Status400BadRequest, - // ReSharper disable once RedundantArgumentDefaultValue - "application/problem+json" - )) - .WithMetadata(new ProducesPortableProblemAttribute( - StatusCodes.Status400BadRequest, - "application/PROBLEM+JSON" - )); + .WithMetadata( + new ProducesPortableProblemAttribute( + StatusCodes.Status400BadRequest, + // ReSharper disable once RedundantArgumentDefaultValue + "application/problem+json" + ) + ) + .WithMetadata( + new ProducesPortableProblemAttribute( + StatusCodes.Status400BadRequest, + "application/PROBLEM+JSON" + ) + ); } ); @@ -285,15 +290,19 @@ public async Task Transformer_ShouldMergeResponseSchemas_WhenContentTypeOnlyDiff { webApplication .MapGet("/minimal/problems/union-casing", static () => TypedResults.Problem()) - .WithMetadata(new ProducesPortableProblemAttribute( - StatusCodes.Status400BadRequest, - // ReSharper disable once RedundantArgumentDefaultValue - "application/problem+json" - )) - .WithMetadata(new ProducesPortableValidationProblemAttribute( - StatusCodes.Status400BadRequest, - "application/PROBLEM+JSON" - )); + .WithMetadata( + new ProducesPortableProblemAttribute( + StatusCodes.Status400BadRequest, + // ReSharper disable once RedundantArgumentDefaultValue + "application/problem+json" + ) + ) + .WithMetadata( + new ProducesPortableValidationProblemAttribute( + StatusCodes.Status400BadRequest, + "application/PROBLEM+JSON" + ) + ); } ); @@ -362,7 +371,11 @@ public async Task Transformer_ShouldReplaceExistingSchemaForTheSameResponseSlot( options.AddOperationTransformer( (operation, context, _) => { - if (!string.Equals(context.Description.RelativePath, "minimal/success/replaces-existing", StringComparison.Ordinal)) + if (!string.Equals( + context.Description.RelativePath, + "minimal/success/replaces-existing", + StringComparison.Ordinal + )) { return Task.CompletedTask; } @@ -374,7 +387,7 @@ public async Task Transformer_ShouldReplaceExistingSchemaForTheSameResponseSlot( Description = "HTTP 200", Content = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["application/json"] = new() + ["application/json"] = new () { Schema = new OpenApiSchema { @@ -454,6 +467,31 @@ public void ErrorMetadataContractsBuilder_ShouldRejectSanitizedCodeCollisions() act.Should().Throw().WithMessage("*Code/One*Code_One*"); } + [Fact] + public void OpenApiModule_ShouldRegisterErrorMetadataRegistryOnlyOnce() + { + var services = new ServiceCollection(); + services.AddOptions(); + + services.ConfigureErrorMetadataContracts( + contracts => contracts.ForCode("VersionMismatch") + ); + services.AddPortableResultsOpenApi(); + services.ConfigureErrorMetadataContracts( + contracts => contracts.ForCode("Insufficient/Funds") + ); + services.AddPortableResultsOpenApi(); + + services.Where(static descriptor => descriptor.ServiceType == typeof(IPortableErrorMetadataContractRegistry)) + .Should() + .ContainSingle(); + + using var serviceProvider = services.BuildServiceProvider(); + var registry = serviceProvider.GetRequiredService(); + registry.Contracts.Should().ContainKey("VersionMismatch"); + registry.Contracts.Should().ContainKey("Insufficient/Funds"); + } + private static async Task GetOpenApiDocumentAsync(WebApplication app) { await app.StartAsync(TestContext.Current.CancellationToken); @@ -662,4 +700,3 @@ public sealed class FundsMetadata public decimal MissingAmount { get; init; } } // ReSharper restore UnusedMember.Global - From d4512acb2877b4d6921d691e9216ca6b9dbbc6ca Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 14:33:12 +0200 Subject: [PATCH 35/67] chore: PortableOpenApiSuccessResponseAttributeBase now indicates why MetadataSerializationMode? is not possible Signed-off-by: Kenny Pflug --- ...ableOpenApiSuccessResponseAttributeBase.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs index 0ea011b..cd83deb 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs @@ -6,6 +6,14 @@ namespace Light.PortableResults.AspNetCore.OpenApi; /// /// Base class for Light.PortableResults success-response OpenAPI metadata. /// +/// +/// The metadata serialization mode override is represented by the combination of +/// and . +/// This type intentionally does not use a nullable enum property because MVC attribute named arguments +/// must use attribute-compatible property types; changing the property to +/// would trigger compiler error CS0655 for usages such as +/// [ProducesPortableSuccessResponse(MetadataSerializationMode = MetadataSerializationMode.Always)]. +/// public abstract class PortableOpenApiSuccessResponseAttributeBase : PortableOpenApiResponseAttributeBase { /// @@ -27,8 +35,14 @@ protected PortableOpenApiSuccessResponseAttributeBase(int statusCode, string con public Type ValueType { get; } /// - /// Gets or sets the optional documentation-only override for the metadata serialization mode. + /// Gets or sets the documentation-only override for the metadata serialization mode. /// + /// + /// Read this property together with . + /// When is , + /// the value returned by this property is only the enum's default value and does not indicate + /// that an explicit override was configured. + /// public MetadataSerializationMode MetadataSerializationMode { get; @@ -40,7 +54,11 @@ public MetadataSerializationMode MetadataSerializationMode } /// - /// Indicates whether the metadata serialization mode has been explicitly overridden. + /// Indicates whether was explicitly overridden. /// + /// + /// This mirror property exists because attribute properties cannot use a nullable enum type without + /// breaking MVC attribute named arguments with compiler error CS0655. + /// public bool HasMetadataSerializationModeOverride { get; private set; } } \ No newline at end of file From 7a0af3be093365d6416c15fa0b07717a3f0689c8 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 14:41:41 +0200 Subject: [PATCH 36/67] chore: use HttpMethod.Parse in TryGetOperation Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiDocumentTransformer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs index f74724d..e6f15a3 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs @@ -628,7 +628,7 @@ private static bool TryGetOperation( var httpMethod = string.IsNullOrWhiteSpace(apiDescription.HttpMethod) ? null : - new HttpMethod(apiDescription.HttpMethod); + HttpMethod.Parse(apiDescription.HttpMethod); if (httpMethod is null || pathItem.Operations is null || From afb8543c6c2e48179c2c6b82f78314d5cb0494df Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 14:59:03 +0200 Subject: [PATCH 37/67] refactor: introduce subnamespaces in OpenAPI project Signed-off-by: Kenny Pflug --- .../IPortableErrorMetadataContractRegistry.cs | 2 +- .../PortableErrorMetadataContractRegistry.cs | 4 +++- .../PortableErrorMetadataContractsBuilder.cs | 4 +++- .../PortableErrorMetadataContractsOptions.cs | 2 +- .../PortableResultsOpenApiDocumentTransformer.cs | 4 +++- .../{ => Generation}/PortableResultsOpenApiMessages.cs | 2 +- .../PortableResultsOpenApiModule.cs | 2 ++ .../{ => Schemas}/PortableResultsOpenApiSchemaNaming.cs | 2 +- .../{ => Schemas}/PortableResultsOpenApiSchemas.cs | 2 +- .../PortableResultsOpenApiDocumentTransformerTests.cs | 1 + .../PortableResultsOpenApiSchemasTests.cs | 1 + 11 files changed, 18 insertions(+), 8 deletions(-) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => ErrorContracts}/IPortableErrorMetadataContractRegistry.cs (85%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => ErrorContracts}/PortableErrorMetadataContractRegistry.cs (93%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => ErrorContracts}/PortableErrorMetadataContractsBuilder.cs (92%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => ErrorContracts}/PortableErrorMetadataContractsOptions.cs (83%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => Generation}/PortableResultsOpenApiDocumentTransformer.cs (99%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => Generation}/PortableResultsOpenApiMessages.cs (94%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => Schemas}/PortableResultsOpenApiSchemaNaming.cs (99%) rename src/Light.PortableResults.AspNetCore.OpenApi/{ => Schemas}/PortableResultsOpenApiSchemas.cs (99%) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs similarity index 85% rename from src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs index e19805a..b190036 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/IPortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Provides the global map of documented error-code metadata contracts. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs similarity index 93% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs index 344fd78..a496c01 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using Light.PortableResults.AspNetCore.OpenApi.Generation; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Default implementation of . diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs similarity index 92% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs index a43f7dc..bbe12ca 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using Light.PortableResults.AspNetCore.OpenApi.Generation; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Builds the global map of documented error-code metadata contracts. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsOptions.cs similarity index 83% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsOptions.cs index 7b5ffa1..a43a7d3 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableErrorMetadataContractsOptions.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsOptions.cs @@ -1,4 +1,4 @@ -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Options backing the global error-code metadata contract registry. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs similarity index 99% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index e6f15a3..0a0ca75 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -7,6 +7,8 @@ using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; using Light.PortableResults.Http.Writing; using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -15,7 +17,7 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.Generation; /// /// OpenAPI document transformer for Light.PortableResults. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs similarity index 94% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index a4b0dfd..1f0fcf8 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -1,6 +1,6 @@ using System; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.Generation; internal static class PortableResultsOpenApiMessages { diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs index 16e7bee..40113c3 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -1,5 +1,7 @@ using System; using System.Linq; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.AspNetCore.OpenApi.Generation; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemaNaming.cs similarity index 99% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemaNaming.cs index 6da18f8..b93ed70 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemaNaming.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemaNaming.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.OpenApi; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.Schemas; /// /// Provides helpers for creating stable OpenAPI component schema ids used by Light.PortableResults. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs similarity index 99% rename from src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs index 807e4e7..74c4298 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiSchemas.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs @@ -4,7 +4,7 @@ using System.Text.Json.Nodes; using Microsoft.OpenApi; -namespace Light.PortableResults.AspNetCore.OpenApi; +namespace Light.PortableResults.AspNetCore.OpenApi.Schemas; /// /// Installs the canonical Light.PortableResults OpenAPI schemas into a document. diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 204c7d7..f222d40 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -7,6 +7,7 @@ using FluentAssertions; using Light.PortableResults.AspNetCore.MinimalApis; using Light.PortableResults.AspNetCore.Mvc; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Light.PortableResults.Http.Writing; using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Builder; diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs index e8bb0e3..690289c 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; using Microsoft.OpenApi; using Xunit; From 3d241ae0f8e10616f56dcaf522ada00d88043b05 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 15:23:24 +0200 Subject: [PATCH 38/67] chore: adjust AI plan 0040-2 to use new OpenAPI namespaces Signed-off-by: Kenny Pflug --- ai-plans/0040-2-validation-error-contracts.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/ai-plans/0040-2-validation-error-contracts.md b/ai-plans/0040-2-validation-error-contracts.md index faeed6c..0361b58 100644 --- a/ai-plans/0040-2-validation-error-contracts.md +++ b/ai-plans/0040-2-validation-error-contracts.md @@ -2,23 +2,23 @@ ## Rationale -Plan `0040-1-openapi-redesign.md` introduces `IPortableErrorMetadataContractRegistry`, which maps error code strings to CLR metadata types so the OpenAPI document transformer can narrow `errors[*].metadata` and `errorDetails[*].metadata` to accurate schemas per code. +Plan `0040-1-openapi-redesign.md` introduces `IPortableErrorMetadataContractRegistry` in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`, which maps error code strings to CLR metadata types so the OpenAPI document transformer can narrow `errors[*].metadata` and `errorDetails[*].metadata` to accurate schemas per code. The `Light.PortableResults.Validation` package already defines a stable code-plus-metadata taxonomy through its built-in `ValidationErrorDefinition` subclasses (`CountValidationErrorDefinition`, `MinCountValidationErrorDefinition`, `GreaterThanValidationErrorDefinition`, `RegexValidationErrorDefinition`, `EnumNameValidationErrorDefinition`, `PrecisionScaleValidationErrorDefinition`, etc.). The metadata keys are centralized in `ValidationErrorMetadataKeys`. Without this follow-up, every caller who uses built-in validation error definitions has to redeclare contracts the library already owns. Two aspects of the built-in contracts make a pure CLR-type registration awkward: -1. **Polymorphic primitive values.** `CreateMetadataValue` in `BuiltInValidationErrorDefinitions.Shared.cs` projects any `T` down to one of `null | boolean | int64 | double | decimal | string` for primitives. A code like `GreaterThan` is used for integers, dates, strings, and more \u2014 the metadata schema for `comparativeValue` is honestly a JSON-primitive union, not a single CLR type. -2. **Package boundary.** `Light.PortableResults.AspNetCore.OpenApi` (where `IPortableErrorMetadataContractRegistry` lives, per `0040-1`) does not and should not depend on `Light.PortableResults.Validation`. The built-in contracts must live in the validation package and opt in from there. +1. **Polymorphic primitive values.** `CreateMetadataValue` in `BuiltInValidationErrorDefinitions.Shared.cs` projects any `T` down to one of `null | boolean | int64 | double | decimal | string` for primitives. A code like `GreaterThan` is used for integers, dates, strings, and more - the metadata schema for `comparativeValue` is honestly a JSON-primitive union, not a single CLR type. +2. **Package boundary.** `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (where `IPortableErrorMetadataContractRegistry` lives, per `0040-1`) does not and should not depend on `Light.PortableResults.Validation`. The built-in contracts must live in the validation package and opt in from there. -This plan widens the registry to also accept pre-authored `OpenApiSchema` values, ships a catalog of canonical schemas for every built-in validation error code that carries metadata, and adds a one-line opt-in extension. It also exposes the built-in codes as compile-time constants so callers get IntelliSense and refactor safety when opting into specific codes. +This plan widens the registry to also accept pre-authored `OpenApiSchema` values, ships a catalog of canonical schemas for every built-in validation error code that carries metadata, and adds a one-line opt-in extension. It also exposes the built-in codes as compile-time constants, so callers get IntelliSense and refactor safety when opting into specific codes. ## Acceptance Criteria -- [ ] `PortableErrorMetadataContract` is introduced as a public readonly struct in `Light.PortableResults.AspNetCore.OpenApi` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of either a CLR `Type` (to be run through the ASP.NET Core schema generator) or a pre-authored `OpenApiSchema`. It exposes `static FromType(Type metadataType)`, `static FromSchema(OpenApiSchema metadataSchema)`, a `Kind` enum property (`Type` / `Schema`), and `TryGetType(out Type)` / `TryGetSchema(out OpenApiSchema)` accessors. +- [ ] `PortableErrorMetadataContract` is introduced as a public readonly struct in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of either a CLR `Type` (to be run through the ASP.NET Core schema generator) or a pre-authored `OpenApiSchema`. It exposes `static FromType(Type metadataType)`, `static FromSchema(OpenApiSchema metadataSchema)`, a `Kind` enum property (`Type` / `Schema`), and `TryGetType(out Type)` / `TryGetSchema(out OpenApiSchema)` accessors. - [ ] `IPortableErrorMetadataContractRegistry.Contracts` is widened from `IReadOnlyDictionary` to `IReadOnlyDictionary`. The default implementation and its tests are updated accordingly. - [ ] `PortableErrorMetadataContractsBuilder` gains a new overload `ForCode(string code, OpenApiSchema metadataSchema)`. The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. -- [ ] `PortableResultsOpenApiDocumentTransformer` is updated to dispatch on `PortableErrorMetadataContract.Kind` when materializing registry entries into `Components.Schemas`: `Type` entries go through the ASP.NET Core schema generator as before, `Schema` entries are installed directly. +- [ ] `PortableResultsOpenApiDocumentTransformer` in `Light.PortableResults.AspNetCore.OpenApi.Generation` is updated to dispatch on `PortableErrorMetadataContract.Kind` when materializing registry entries into `Components.Schemas`: `Type` entries go through the ASP.NET Core schema generator as before, `Schema` entries are installed directly. - [ ] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains hand-authored canonical schemas for every built-in validation error code that carries metadata (`Count`, `MinCount`, `MaxCount`, `Length`, `MinLength`, `MaxLength`, `LengthInRange`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`), using the exact JSON property names defined in `ValidationErrorMetadataKeys`. - [ ] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives — `{ type: string }`, `{ type: number }`, `{ type: integer }`, `{ type: boolean }`, `{ type: "null" }` — matching what `CreateMetadataValue` actually produces on the wire. - [ ] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (e.g. `Count`, `MinCount`, `GreaterThan`, `Pattern`, `EnumName`, `PrecisionScale`, `NotNull`, `NotEmpty`, `Empty`, `Predicate`, ...). The constant values match the code strings assigned in the built-in definition constructors exactly. The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. @@ -32,19 +32,27 @@ This plan widens the registry to also accept pre-authored `OpenApiSchema` values ### Contract Widening -`PortableErrorMetadataContract` is a small readonly struct, not an interface, so the hot path stays allocation-free. The default implementation of `IPortableErrorMetadataContractRegistry` stores entries in a `Dictionary`. Existing call sites that wrote `Type` directly are updated to wrap with `PortableErrorMetadataContract.FromType(...)`. The public `ForCode(string)` and `ForCode(string, Type)` overloads are unchanged. +`PortableErrorMetadataContract` is a small readonly struct, not an interface, so the hot path stays allocation-free. The type, its `Kind` enum, the builder overloads, and the registry abstractions stay together in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`. The default implementation of `IPortableErrorMetadataContractRegistry` stores entries in a `Dictionary`. Existing call sites that wrote `Type` directly are updated to wrap with `PortableErrorMetadataContract.FromType(...)`. The public `ForCode(string)` and `ForCode(string, Type)` overloads are unchanged. The new `ForCode(string code, OpenApiSchema metadataSchema)` overload clones the provided schema defensively (using `OpenApiSchema`'s copy constructor) so later mutations by callers do not leak into the registry. ### Transformer Dispatch -When the transformer synthesizes the canonical `PortableError_` and `PortableValidationErrorDetail_` schemas, it reads the contract and: +When the transformer in `Light.PortableResults.AspNetCore.OpenApi.Generation` synthesizes the canonical `PortableError_` and `PortableValidationErrorDetail_` schemas, it reads the contract and: - For `PortableErrorMetadataContract.Kind == Type`, runs the CLR type through the ASP.NET Core schema generator exposed by `OpenApiDocumentTransformerContext` (unchanged behavior). - For `PortableErrorMetadataContract.Kind == Schema`, installs the provided schema under the name `Metadata` (if not already present) and `$ref`s it from the narrowed code schema. Naming for schema-based contracts uses `Metadata` (for example `CountMetadata`, `GreaterThanMetadata`) so they are discoverable in generated client code and do not collide with user types. +### Project Structure + +The follow-up should respect the current OpenAPI project slices: + +- `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` contains the contract-registration model (`PortableErrorMetadataContract`, its `Kind` enum, builder overloads, options, and registry implementation). +- `Light.PortableResults.AspNetCore.OpenApi.Generation` contains transformer changes and any internal message helpers needed to materialize schema-based contracts into the document. +- `Light.PortableResults.AspNetCore.OpenApi.Schemas` continues to hold general reusable schema catalog and naming helpers only; the built-in validation contract catalog itself remains in `Light.PortableResults.Validation` and is not moved into the OpenAPI package. + ### Built-In Contract Catalog `BuiltInValidationErrorContracts.Contracts` is built once (static readonly). Each entry authors an `OpenApiSchema` with `Type = "object"`, the exact property keys from `ValidationErrorMetadataKeys`, and `Required` populated to match. Examples of the authored shapes: @@ -77,7 +85,7 @@ MetadataPrimitiveValue: ### Package Wiring -`Light.PortableResults.Validation` takes on a `Microsoft.OpenApi` package reference to author `OpenApiSchema` instances. The reference is compile-time only; the validation package does not reference `Microsoft.AspNetCore.OpenApi` or `Light.PortableResults.AspNetCore.OpenApi` and remains usable from non-ASP.NET Core hosts — `RegisterBuiltInValidationErrors` is an extension on `PortableErrorMetadataContractsBuilder` (declared in `Light.PortableResults.AspNetCore.OpenApi`), so callers who pull in the validation package without the OpenAPI package simply never see the extension in scope. The `RegisterBuiltInValidationErrors` extension method lives in a file under the same namespace as `BuiltInValidationErrorContracts` so a single `using` import exposes the opt-in along with the constants. +`Light.PortableResults.Validation` takes on a `Microsoft.OpenApi` package reference to author `OpenApiSchema` instances. The reference is compile-time only; the validation package does not reference `Microsoft.AspNetCore.OpenApi` or `Light.PortableResults.AspNetCore.OpenApi` and remains usable from non-ASP.NET Core hosts — `RegisterBuiltInValidationErrors` is an extension on `PortableErrorMetadataContractsBuilder` (declared in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`), so callers who pull in the validation package without the OpenAPI package simply never see the extension in scope. The `RegisterBuiltInValidationErrors` extension method lives in a file under the same namespace as `BuiltInValidationErrorContracts` so a single `using` import exposes the opt-in along with the constants. ### Scope Boundaries From 4c090ddfea6910e3f3adfd0a628bb8a0ec32b47b Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 17:35:57 +0200 Subject: [PATCH 39/67] chore: update plan 0040-2 so that it respects recent developments Signed-off-by: Kenny Pflug --- ai-plans/0040-2-validation-error-contracts.md | 180 +++++++++++++----- 1 file changed, 137 insertions(+), 43 deletions(-) diff --git a/ai-plans/0040-2-validation-error-contracts.md b/ai-plans/0040-2-validation-error-contracts.md index 0361b58..b861564 100644 --- a/ai-plans/0040-2-validation-error-contracts.md +++ b/ai-plans/0040-2-validation-error-contracts.md @@ -4,91 +4,185 @@ Plan `0040-1-openapi-redesign.md` introduces `IPortableErrorMetadataContractRegistry` in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`, which maps error code strings to CLR metadata types so the OpenAPI document transformer can narrow `errors[*].metadata` and `errorDetails[*].metadata` to accurate schemas per code. -The `Light.PortableResults.Validation` package already defines a stable code-plus-metadata taxonomy through its built-in `ValidationErrorDefinition` subclasses (`CountValidationErrorDefinition`, `MinCountValidationErrorDefinition`, `GreaterThanValidationErrorDefinition`, `RegexValidationErrorDefinition`, `EnumNameValidationErrorDefinition`, `PrecisionScaleValidationErrorDefinition`, etc.). The metadata keys are centralized in `ValidationErrorMetadataKeys`. Without this follow-up, every caller who uses built-in validation error definitions has to redeclare contracts the library already owns. +The `Light.PortableResults.Validation` package already defines a stable code-plus-metadata taxonomy through its built-in `ValidationErrorDefinition` subclasses (`CountValidationErrorDefinition`, `MinCountValidationErrorDefinition`, `GreaterThanValidationErrorDefinition`, `PatternValidationErrorDefinition`, `EnumNameValidationErrorDefinition`, `PrecisionScaleValidationErrorDefinition`, etc.). The metadata keys are centralized in `ValidationErrorMetadataKeys`. Without this follow-up, every caller who uses built-in validation error definitions has to redeclare contracts the library already owns. -Two aspects of the built-in contracts make a pure CLR-type registration awkward: +Three aspects of the built-in contracts make a pure CLR-type registration awkward: -1. **Polymorphic primitive values.** `CreateMetadataValue` in `BuiltInValidationErrorDefinitions.Shared.cs` projects any `T` down to one of `null | boolean | int64 | double | decimal | string` for primitives. A code like `GreaterThan` is used for integers, dates, strings, and more - the metadata schema for `comparativeValue` is honestly a JSON-primitive union, not a single CLR type. -2. **Package boundary.** `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (where `IPortableErrorMetadataContractRegistry` lives, per `0040-1`) does not and should not depend on `Light.PortableResults.Validation`. The built-in contracts must live in the validation package and opt in from there. +1. **Code-level polymorphism.** Built-in comparison and range codes are shared across many `T`s, but the global registry is keyed only by error code. `CreateMetadataValue` in `BuiltInValidationErrorDefinitions.Shared.cs` projects any `T` down to one of `null | boolean | int64 | double | decimal | string` for primitives, so the global code-level contract for a code like `GreaterThan` must document a broad JSON-primitive shape. Endpoint-specific typed helpers introduced by this plan then narrow that broad fallback to the concrete `T` when the application can provide it. +2. **Layering.** `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (where `IPortableErrorMetadataContractRegistry` lives, per `0040-1`) does not and should not depend on `Light.PortableResults.Validation`. Conversely, `Light.PortableResults.Validation` is an OpenAPI-agnostic foundation used from messaging, gRPC, and console hosts; it must not take on a `Microsoft.OpenApi` package reference or any direct knowledge of the OpenAPI registry. The built-in contract catalog therefore lives in neither package. +3. **Spec-version dependence.** The polymorphic primitive schema (`null | string | number | integer | boolean`) cannot be authored once for every OpenAPI version. OpenAPI 3.0 has no `null` type and instead expresses nullability via `nullable: true`; OpenAPI 3.1+ uses `{ type: "null" }`. The transformer already branches on `OpenApiSpecVersion` for `const` vs `enum` narrowing and must do the same here. -This plan widens the registry to also accept pre-authored `OpenApiSchema` values, ships a catalog of canonical schemas for every built-in validation error code that carries metadata, and adds a one-line opt-in extension. It also exposes the built-in codes as compile-time constants, so callers get IntelliSense and refactor safety when opting into specific codes. +This plan widens the registry to also accept pre-authored `OpenApiSchema` values produced by a per-code factory, introduces a new bridge package `Light.PortableResults.Validation.OpenApi` that owns the catalog and the opt-in extension, and exposes the built-in error codes as compile-time constants on the validation package itself so callers get IntelliSense and refactor safety even when the OpenAPI package is not in scope. ## Acceptance Criteria -- [ ] `PortableErrorMetadataContract` is introduced as a public readonly struct in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of either a CLR `Type` (to be run through the ASP.NET Core schema generator) or a pre-authored `OpenApiSchema`. It exposes `static FromType(Type metadataType)`, `static FromSchema(OpenApiSchema metadataSchema)`, a `Kind` enum property (`Type` / `Schema`), and `TryGetType(out Type)` / `TryGetSchema(out OpenApiSchema)` accessors. +- [ ] `PortableErrorMetadataContract` is introduced as a public abstract base class with a library-owned closed set of sealed subclasses in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of (a) a CLR `Type` (to be run through the ASP.NET Core schema generator), (b) a per-code `Func` factory, or (c) the absence of metadata. The base type is `public abstract class PortableErrorMetadataContract` exposing `static FromType(Type metadataType)` and `static FromSchema(Func schemaFactory)` factory methods and a `static PortableErrorMetadataContract NoMetadata { get; }` singleton. No public `Kind` enum is exposed; the concrete subclass is the discriminator and the transformer dispatches via pattern matching. Three sealed subclasses `PortableErrorMetadataTypeContract`, `PortableErrorMetadataSchemaContract`, and `PortableErrorMetadataNoMetadataContract` carry the respective payloads; the type and factory subclasses expose their payloads as public read-only properties. The factory shape (rather than a static `OpenApiSchema` instance) is mandatory because `OpenApiSchema` is a mutable POCO; storing a single instance in a static catalog leaks mutations across consumer hosts. The factory accepts the resolved `OpenApiSpecVersion` so spec-version-dependent shapes can author themselves correctly; callers that do not need the version simply ignore the parameter. OpenAPI document generation runs only during application startup, so neither the factory invocation nor the abstract-class allocation is perf-sensitive. - [ ] `IPortableErrorMetadataContractRegistry.Contracts` is widened from `IReadOnlyDictionary` to `IReadOnlyDictionary`. The default implementation and its tests are updated accordingly. -- [ ] `PortableErrorMetadataContractsBuilder` gains a new overload `ForCode(string code, OpenApiSchema metadataSchema)`. The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. -- [ ] `PortableResultsOpenApiDocumentTransformer` in `Light.PortableResults.AspNetCore.OpenApi.Generation` is updated to dispatch on `PortableErrorMetadataContract.Kind` when materializing registry entries into `Components.Schemas`: `Type` entries go through the ASP.NET Core schema generator as before, `Schema` entries are installed directly. -- [ ] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains hand-authored canonical schemas for every built-in validation error code that carries metadata (`Count`, `MinCount`, `MaxCount`, `Length`, `MinLength`, `MaxLength`, `LengthInRange`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`), using the exact JSON property names defined in `ValidationErrorMetadataKeys`. -- [ ] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives — `{ type: string }`, `{ type: number }`, `{ type: integer }`, `{ type: boolean }`, `{ type: "null" }` — matching what `CreateMetadataValue` actually produces on the wire. -- [ ] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (e.g. `Count`, `MinCount`, `GreaterThan`, `Pattern`, `EnumName`, `PrecisionScale`, `NotNull`, `NotEmpty`, `Empty`, `Predicate`, ...). The constant values match the code strings assigned in the built-in definition constructors exactly. The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. -- [ ] A public extension method `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder builder)` is added in `Light.PortableResults.Validation`. It iterates `BuiltInValidationErrorContracts.Contracts` and calls the new `ForCode(string, OpenApiSchema)` overload for each entry. Codes without metadata (for example `NotNull`, `NotEmpty`, `Empty`, `Predicate`) are intentionally not registered because there is no metadata shape to narrow. -- [ ] `Light.PortableResults.Validation.csproj` adds a package reference to `Microsoft.OpenApi` (the `Microsoft.OpenApi.Models` types only — no ASP.NET Core dependency; the validation package must remain usable from non-ASP.NET Core hosts). The package targets `netstandard2.0` so this reference must be compatible with that target. A corresponding `` entry is added to `Directory.Packages.props`. -- [ ] The `NativeAotMovieRating` sample is updated to call `.RegisterBuiltInValidationErrors()` inside `ConfigureErrorMetadataContracts` and to opt its endpoints into the relevant built-in codes via `WithErrorCodes(ValidationErrorCodes.Count, ...)`. -- [ ] Automated tests cover: the discriminated-union behavior of `PortableErrorMetadataContract`, the schema output for every built-in code (round-tripped against the taxonomy in `ValidationErrorMetadataKeys`), the `oneOf`-over-primitives shape for typed-value codes, the `RegisterBuiltInValidationErrors` extension registering the expected set of codes, and an end-to-end scenario where an endpoint opts into a built-in code and the generated OpenAPI document contains the narrowed schema. -- [ ] `README.md` is updated to describe the opt-in one-liner, the built-in taxonomy surfaced by `ValidationErrorCodes`, and the fact that user-defined codes continue to register through the existing type-based overloads. +- [ ] `PortableErrorMetadataContractsBuilder` gains two new overloads: `ForCode(string code, Func metadataSchemaFactory)` (storing `PortableErrorMetadataContract.FromSchema(...)`) and `ForCode(string code)` (storing `PortableErrorMetadataContract.NoMetadata` for codes that the runtime never decorates with metadata). The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. Re-registering the same code with an equivalent contract is idempotent; registering the same code with a different contract throws a clear duplicate-contract exception instead of using last-writer-wins. +- [ ] `PortableResultsOpenApiDocumentTransformer` in `Light.PortableResults.AspNetCore.OpenApi.Generation` is updated to dispatch on the concrete subclass when materializing registry entries: `PortableErrorMetadataTypeContract` entries go through the ASP.NET Core schema generator as before; `PortableErrorMetadataSchemaContract` entries invoke the factory once per generated metadata component (passing the resolved `OpenApiSpecVersion`), install the produced schema, and `$ref` it from the narrowed code schema; `PortableErrorMetadataNoMetadataContract` entries emit the narrowed envelope without a `metadata` reference at all. Concretely, for type and schema contracts the synthesized extension schema continues to be `{ properties: { code: const, metadata: $ref }, required: [code] }`; for no-metadata contracts the extension schema is `{ properties: { code: const }, required: [code] }`, which leaves `metadata` to inherit from the base schema (open object, nullable). This is faithful to the wire — the runtime simply does not write a `metadata` property for these codes — and matches the canonical envelope's nullability. Schema-based contracts therefore only replace the `metadata` reference target, no-metadata contracts remove it entirely, and the narrowed-envelope `allOf [base, extension]` construction in `CreateCodeSpecificSchema` is otherwise unchanged. All three contract kinds share one component-id namespace. +- [ ] Schema-based metadata components are stored under the same naming convention used for type-based metadata: `____Metadata` (for example `PortableError__Count__Metadata`), produced by the existing `PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(...)` helper. There is no flat `Metadata` namespace; both contract kinds live in the same component-id space so tools that walk `Components.Schemas` see one rule rather than two. +- [ ] A new project `Light.PortableResults.Validation.OpenApi` is added to the solution. It targets .NET 10, sets `true`, and project-references both `Light.PortableResults.Validation` and `Light.PortableResults.AspNetCore.OpenApi`. `Light.PortableResults.Validation` itself does **not** gain a `Microsoft.OpenApi` reference and remains OpenAPI-agnostic so non-ASP.NET-Core hosts (messaging, gRPC, console) carry no transitive OpenAPI dependency. +- [ ] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation.OpenApi` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains one entry per built-in validation error code that has a stable framework-level shape: + - Codes that carry metadata (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`) are stored as `PortableErrorMetadataSchemaContract` instances whose factory returns a fresh `OpenApiSchema` on each invocation, using the exact JSON property names defined in `ValidationErrorMetadataKeys`. + - Codes that the framework guarantees emit no metadata (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`) are stored as `PortableErrorMetadataContract.NoMetadata`. These are included so consumers can opt them into endpoints via `WithErrorCodes` without falling back to the inline `WithErrorMetadata` escape hatch with a synthetic empty type. + - `Predicate` is intentionally excluded because it is the default code emitted by `Must(...)` overloads (`Checks.Predicate.cs`), which routinely accept caller-supplied `ValidationErrorDefinition` instances with bespoke metadata shapes. A globally registered no-metadata contract for `Predicate` would lock the schema for those flows and conflict with what consumers actually want to document. +- [ ] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives. The exact branches depend on the resolved `OpenApiSpecVersion` of the document, mirroring the existing `const` vs `enum` branch in the transformer: + - OpenAPI 3.1+: `oneOf: [{ type: string }, { type: number }, { type: integer }, { type: boolean }, { type: "null" }]`. + - OpenAPI 3.0: `oneOf: [{ type: string }, { type: number }, { type: integer }, { type: boolean }]` plus `nullable: true` on the parent property; the `null` branch is omitted because OpenAPI 3.0 does not support `type: "null"`. +- [ ] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`, `NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`, `Predicate`). The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. Because the library is pre-stable, this plan also improves the current runtime code strings for developer experience: `LengthIn` becomes `LengthInRange`, `Matches` becomes `Pattern`, `IsInBetween` becomes `InRange`, and `NotInBetween` becomes `NotInRange`. `ValidationErrorCodes` stays in `Light.PortableResults.Validation` (not the new bridge package) because the constants are independently useful in switch arms, message templates, and inline error metadata even when the OpenAPI package is not referenced. +- [ ] A public extension method `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder builder)` is added in `Light.PortableResults.Validation.OpenApi`. It iterates `BuiltInValidationErrorContracts.Contracts` and registers each entry into the builder by dispatching on the contract subclass: schema entries call the factory overload, no-metadata entries call `ForCode(string)`, and any future type entries would call the existing `ForCode(string, Type)` overload. `Predicate` is intentionally not registered for the reason described above; consumers who want to document a `Predicate` flow either supply their own `ValidationErrorDefinition` with a custom code and register that, or use the inline `WithErrorMetadata` escape hatch on the relevant endpoint. +- [ ] Nine generic CLR record types are added to `Light.PortableResults.Validation.OpenApi` to back per-endpoint narrowing of the polymorphic comparison and range codes: `EqualToMetadata(T ComparativeValue)`, `NotEqualToMetadata(T ComparativeValue)`, `GreaterThanMetadata(T ComparativeValue)`, `GreaterThanOrEqualToMetadata(T ComparativeValue)`, `LessThanMetadata(T ComparativeValue)`, `LessThanOrEqualToMetadata(T ComparativeValue)`, `InRangeMetadata(T LowerBoundary, T UpperBoundary)`, `NotInRangeMetadata(T LowerBoundary, T UpperBoundary)`, and `ExclusiveRangeMetadata(T LowerBoundary, T UpperBoundary)`. These are the only built-in codes whose metadata genuinely varies in `T` across call sites (every other built-in code is shape-fixed: lengths/counts → integer, regex → string + integer, enum → string + boolean, precision/scale → integer + integer + boolean). Property names match `ValidationErrorMetadataKeys` exactly so the schema generator's casing convention produces the wire-correct keys (`comparativeValue`, `lowerBoundary`, `upperBoundary`). +- [ ] A static class `BuiltInValidationErrorBuilderExtensions` in `Light.PortableResults.Validation.OpenApi` exposes typed extension methods on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. Each helper is a thin wrapper over the existing inline escape hatch — for example `WithInRangeError()` calls `WithErrorMetadata>(ValidationErrorCodes.InRange)` — so the transformer needs no new code path, the endpoint-scoped component id naming (`PortableError________InRange`) is reused, and the resulting schema for the typed bound (`integer` for `T = int`, `string` with `format: date-time` for `T = DateTime`, etc.) comes out of the standard ASP.NET Core schema generator. Endpoints that mix global and narrowed contracts (e.g. `WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()`) get the global-component reuse for the polymorphism-free codes and the operation-scoped narrowed component for `InRange`, in the same discriminated `anyOf`. +- [ ] `Light.PortableResults.Validation.OpenApi.csproj` adds a package reference to `Microsoft.OpenApi` if not already supplied transitively through `Light.PortableResults.AspNetCore.OpenApi`, and a corresponding `` entry is added to `Directory.Packages.props` if missing. `Light.PortableResults.Validation.csproj` is unchanged on the dependency front. +- [ ] The `NativeAotMovieRating` sample is updated to reference `Light.PortableResults.Validation.OpenApi`, call `.RegisterBuiltInValidationErrors()` inside `ConfigureErrorMetadataContracts`, and opt its endpoints into the relevant built-in codes via `WithErrorCodes(ValidationErrorCodes.Count, ...)`. At least one endpoint demonstrates a narrowed comparison helper (e.g. `.WithInRangeError()` for the rating endpoint that uses `IsInBetween(1, 5)`) so the sample documents the recommended path for site-specific narrowing of polymorphic comparison codes. +- [ ] Automated tests cover: + - The discriminated-union behavior of `PortableErrorMetadataContract` (factory methods, `NoMetadata` singleton, sealed-subclass payloads, pattern-match scenarios, and no public `Kind` enum). + - The schema output for every metadata-bearing built-in code (round-tripped against the taxonomy in `ValidationErrorMetadataKeys`). + - Duplicate-registration behavior: equivalent repeated registrations are idempotent, while conflicting registrations for the same raw code throw a clear error and do not silently use last-writer-wins. + - The validation-code rename: runtime errors emitted by the renamed built-ins use `LengthInRange`, `Pattern`, `InRange`, and `NotInRange`. + - The narrowed envelope for no-metadata codes (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`): the synthesized extension schema constrains `code` only and contains no `metadata` reference. + - The `oneOf`-over-primitives shape for typed-value codes, asserted separately for OpenAPI 3.0 and OpenAPI 3.1+. + - A full document-validation pass that generates an OpenAPI 3.0 document containing the built-in catalog and validates it against an OpenAPI 3.0 validator, so any spec violation introduced by the catalog (such as a stray `type: "null"` branch leaking into a 3.0 document) is caught at test time rather than by consumers. + - A round-trip test that registers the same code via `ForCode(string)` and via `ForCode(string, Func)` and asserts the two transformers produce structurally equivalent narrowed envelopes (modulo schema source), so future refactors cannot silently diverge the type-based and schema-based code paths. + - The `RegisterBuiltInValidationErrors` extension registering the expected set of codes (metadata-bearing plus no-metadata codes; `Predicate` excluded). + - The typed comparison and range helpers: `WithInRangeError()` produces an endpoint-scoped variant whose metadata schema is `{ lowerBoundary: integer, upperBoundary: integer, required: [lowerBoundary, upperBoundary] }`, and `WithInRangeError()` produces `{ type: string, format: date-time }` for both bounds. Equivalent assertions for the equality, greater/less, not-in-range, and exclusive-range helpers. + - A mixed-contract endpoint scenario that mirrors the validator example in the design discussion (`IsNotEmpty`, `HasLengthIn(10, 1000)`, `IsInBetween(1, 5)`): registering the validator endpoint with `.WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()` produces a discriminated `anyOf` whose `NotEmpty` and `LengthInRange` branches reference the global `PortableError__` components and whose `InRange` branch references the endpoint-scoped narrowed component. + - An end-to-end scenario where an endpoint opts into a metadata-bearing built-in code and a no-metadata built-in code (e.g. `Count` and `NotNull`) and the generated OpenAPI document contains the expected narrowed schemas in the discriminated `anyOf`. +- [ ] `README.md` is updated to describe the new `Light.PortableResults.Validation.OpenApi` package and its opt-in one-liner, the built-in taxonomy surfaced by `ValidationErrorCodes`, the typed comparison/range helpers (`WithInRangeError`, `WithGreaterThanError`, `WithEqualToError`, etc.) for site-specific narrowing of polymorphic codes, and the fact that user-defined codes continue to register through the existing type-based overloads on the OpenAPI package. ## Technical Details ### Contract Widening -`PortableErrorMetadataContract` is a small readonly struct, not an interface, so the hot path stays allocation-free. The type, its `Kind` enum, the builder overloads, and the registry abstractions stay together in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`. The default implementation of `IPortableErrorMetadataContractRegistry` stores entries in a `Dictionary`. Existing call sites that wrote `Type` directly are updated to wrap with `PortableErrorMetadataContract.FromType(...)`. The public `ForCode(string)` and `ForCode(string, Type)` overloads are unchanged. +`PortableErrorMetadataContract` is an abstract base class with a library-owned closed set of sealed subclasses rather than a struct. The original draft of this plan used a struct for allocation reasons, but OpenAPI document generation runs only during application startup and is not on any hot path; the closed class hierarchy reads cleanly under pattern matching, makes the concrete runtime type the only discriminator, and avoids the boxing pitfalls of an `enum + nullable payload` struct. It intentionally does not expose a `Kind` enum: a public enum would create a second discriminator that can drift from the payload, and it would imply user extensibility that the transformer cannot honor without a behavior-based custom contract API. Future library-owned variants can still be added as new sealed subclasses. The base type, the three sealed subclasses, the builder overloads, and the registry abstractions stay together in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`. The default implementation of `IPortableErrorMetadataContractRegistry` stores entries in a `Dictionary`. Existing call sites that wrote `Type` directly are updated to wrap with `PortableErrorMetadataContract.FromType(...)`. The public `ForCode(string)` and `ForCode(string, Type)` overloads are unchanged. -The new `ForCode(string code, OpenApiSchema metadataSchema)` overload clones the provided schema defensively (using `OpenApiSchema`'s copy constructor) so later mutations by callers do not leak into the registry. +The new `ForCode(string code, Func metadataSchemaFactory)` overload stores the supplied factory directly in a `PortableErrorMetadataSchemaContract`. The factory shape avoids the cloning question entirely: callers (including the built-in catalog) construct a fresh `OpenApiSchema` per invocation, so no two consumer hosts ever share a mutable schema instance and the registry never has to defensively clone. The spec-version parameter is passed through unchanged from the transformer's per-document resolution. + +The new `ForCode(string code)` overload stores `PortableErrorMetadataContract.NoMetadata`, a singleton `PortableErrorMetadataNoMetadataContract` instance. This variant exists for codes whose framework-level definitions guarantee no metadata is ever attached at runtime (`NotNull`, `Null`, `NotEmpty`, `Empty`); registering them lets consumers opt those codes into endpoints via `WithErrorCodes` without falling back to an inline escape hatch. + +Duplicate registrations are intentionally fail-fast instead of last-writer-wins. A repeated registration is idempotent only when it represents the same contract: the same CLR metadata `Type`, the shared `NoMetadata` singleton, or the same schema factory delegate instance. Any other second registration for the same raw code throws. This keeps option composition predictable: a library can call `RegisterBuiltInValidationErrors()`, an application can add its own codes, and accidental collisions surface at startup instead of silently changing the generated OpenAPI contract based on registration order. If a future consumer needs deliberate global replacement, add an explicit API such as `ReplaceCode(...)` rather than making all `ForCode(...)` calls overwrite by default. ### Transformer Dispatch -When the transformer in `Light.PortableResults.AspNetCore.OpenApi.Generation` synthesizes the canonical `PortableError_` and `PortableValidationErrorDetail_` schemas, it reads the contract and: +When the transformer in `Light.PortableResults.AspNetCore.OpenApi.Generation` synthesizes the canonical `PortableError__` and `PortableValidationErrorDetail__` schemas, it pattern-matches on the contract: -- For `PortableErrorMetadataContract.Kind == Type`, runs the CLR type through the ASP.NET Core schema generator exposed by `OpenApiDocumentTransformerContext` (unchanged behavior). -- For `PortableErrorMetadataContract.Kind == Schema`, installs the provided schema under the name `Metadata` (if not already present) and `$ref`s it from the narrowed code schema. +- For `PortableErrorMetadataTypeContract`, runs the CLR type through the ASP.NET Core schema generator exposed by `OpenApiDocumentTransformerContext` (unchanged behavior). Synthesized extension schema: `{ properties: { code: const, metadata: $ref }, required: [code] }`. +- For `PortableErrorMetadataSchemaContract`, invokes the factory once per generated metadata component (passing the resolved `OpenApiSpecVersion`), installs the produced schema under the existing metadata-component naming convention (`PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(...)`, yielding ids like `PortableError__Count__Metadata` and `PortableValidationErrorDetail__Count__Metadata`), and `$ref`s it from the narrowed code schema. Synthesized extension schema: identical to the type-contract case. +- For `PortableErrorMetadataNoMetadataContract`, emits the narrowed envelope without a `metadata` property at all. Synthesized extension schema: `{ properties: { code: const }, required: [code] }`. The `metadata` slot inherits from the base schema (open object, nullable), which is faithful to the wire — the runtime simply does not write a `metadata` property for these codes. -Naming for schema-based contracts uses `Metadata` (for example `CountMetadata`, `GreaterThanMetadata`) so they are discoverable in generated client code and do not collide with user types. +The `allOf [base, extension]` envelope construction in `CreateCodeSpecificSchema` is otherwise unchanged across all three contract kinds. They share one component-id namespace, and tools that walk `Components.Schemas` see one rule rather than three. ### Project Structure -The follow-up should respect the current OpenAPI project slices: +The follow-up should respect the current OpenAPI project slices and adds one new project: -- `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` contains the contract-registration model (`PortableErrorMetadataContract`, its `Kind` enum, builder overloads, options, and registry implementation). +- `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` contains the contract-registration model (`PortableErrorMetadataContract`, its sealed subclasses, builder overloads, options, and registry implementation). - `Light.PortableResults.AspNetCore.OpenApi.Generation` contains transformer changes and any internal message helpers needed to materialize schema-based contracts into the document. -- `Light.PortableResults.AspNetCore.OpenApi.Schemas` continues to hold general reusable schema catalog and naming helpers only; the built-in validation contract catalog itself remains in `Light.PortableResults.Validation` and is not moved into the OpenAPI package. +- `Light.PortableResults.AspNetCore.OpenApi.Schemas` continues to hold general reusable schema catalog and naming helpers only; the built-in validation contract catalog itself is **not** placed here because it depends on validation-package types. +- `Light.PortableResults.Validation` continues to hold validation primitives. This plan adds `ValidationErrorCodes` (compile-time constants only) so the constants are available even when the OpenAPI package is not referenced. +- `Light.PortableResults.Validation.OpenApi` is a new bridge package that depends on both `Light.PortableResults.Validation` and `Light.PortableResults.AspNetCore.OpenApi`. It hosts `BuiltInValidationErrorContracts` and the `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder)` extension. This split keeps `Light.PortableResults.Validation` free of any `Microsoft.OpenApi` reference and respects the layering of OpenAPI as a higher-level concern than core validation. ### Built-In Contract Catalog -`BuiltInValidationErrorContracts.Contracts` is built once (static readonly). Each entry authors an `OpenApiSchema` with `Type = "object"`, the exact property keys from `ValidationErrorMetadataKeys`, and `Required` populated to match. Examples of the authored shapes: +`BuiltInValidationErrorContracts.Contracts` is a static readonly `IReadOnlyDictionary` — one entry per built-in code that has a stable framework-level shape. Metadata-bearing codes are stored as `PortableErrorMetadataSchemaContract` instances whose factory authors a fresh `OpenApiSchema` (with `Type = JsonSchemaType.Object`, the exact property keys from `ValidationErrorMetadataKeys`, and `Required` populated to match) on each invocation. No-metadata codes (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`) are stored as the shared `PortableErrorMetadataContract.NoMetadata` singleton. Examples of the authored shapes for the metadata-bearing codes: -- `Count` \u2192 `{ expectedCount: integer }`. -- `MinCount` \u2192 `{ minCount: integer }`. -- `MaxCount` \u2192 `{ maxCount: integer }`. -- `Length` / `MinLength` / `MaxLength` \u2192 analogous integer properties. -- `LengthInRange` \u2192 `{ minLength: integer, maxLength: integer }`. -- `GreaterThan` / `GreaterThanOrEqualTo` / `LessThan` / `LessThanOrEqualTo` \u2192 `{ comparativeValue: }`. -- `InRange` \u2192 `{ lowerBoundary: , upperBoundary: }`. -- `Pattern` \u2192 `{ pattern: string, regexOptions: integer }`. -- `Enum` \u2192 `{ enumType: string }`. -- `EnumName` \u2192 `{ enumType: string, ignoreCase: boolean }`. -- `PrecisionScale` \u2192 `{ expectedPrecision: integer, expectedScale: integer, ignoreTrailingZeros: boolean }`. +- `Count` → `{ expectedCount: integer }`. +- `MinCount` → `{ minCount: integer }`. +- `MaxCount` → `{ maxCount: integer }`. +- `MinLength` / `MaxLength` → analogous integer properties. +- `LengthInRange` → `{ minLength: integer, maxLength: integer }`. +- `EqualTo` / `NotEqualTo` / `GreaterThan` / `GreaterThanOrEqualTo` / `LessThan` / `LessThanOrEqualTo` → `{ comparativeValue: }`. +- `InRange` / `NotInRange` / `ExclusiveRange` → `{ lowerBoundary: , upperBoundary: }`. +- `Pattern` → `{ pattern: string, regexOptions: integer }`. +- `Enum` → `{ enumType: string }`. +- `EnumName` → `{ enumType: string, ignoreCase: boolean }`. +- `PrecisionScale` → `{ expectedPrecision: integer, expectedScale: integer, ignoreTrailingZeros: boolean }`. -The `` shape is a shared helper schema referenced by `$ref` to avoid duplication: +The `` shape is spec-version-dependent and is produced by a small helper inside `BuiltInValidationErrorContracts`: -```text -MetadataPrimitiveValue: +- OpenAPI 3.1+: + ```text oneOf: - { type: string } - { type: number } - { type: integer } - { type: boolean } - { type: "null" } + ``` +- OpenAPI 3.0: + ```text + oneOf: + - { type: string } + - { type: number } + - { type: integer } + - { type: boolean } + ``` + with `nullable: true` on the parent property (`comparativeValue`, `lowerBoundary`, `upperBoundary`). The `null` branch is omitted because OpenAPI 3.0 does not support `type: "null"`. + +Although `CreateMetadataValue` distinguishes between `int64`, `double`, and `decimal` at the wire encoding level, OpenAPI collapses the latter two into `number`, so the catalog deliberately does not author a separate `decimal` branch. Tests assert this collapse explicitly so a future contributor does not "fix" it. + +### Typed Helpers for Polymorphic Codes + +The catalog's polymorphic `oneOf` is the only honest documentation for a code-level contract that is genuinely polymorphic across call sites — but for a given endpoint, the call site usually pins down a concrete `T` (e.g. `IsInBetween(1, 5)` makes both bounds `int`). To let consumers declare that concrete `T` in one line without writing CLR DTO scaffolding by hand, the bridge package ships nine generic record types and a matching set of typed builder extensions. + +The records are pre-defined exactly so consumers do not redeclare them per project. The property names match `ValidationErrorMetadataKeys` (`comparativeValue`, `lowerBoundary`, `upperBoundary`) so the schema generator's casing convention emits the wire-correct keys with no further configuration: + +```csharp +namespace Light.PortableResults.Validation.OpenApi; + +public sealed record GreaterThanMetadata(T ComparativeValue); +public sealed record GreaterThanOrEqualToMetadata(T ComparativeValue); +public sealed record LessThanMetadata(T ComparativeValue); +public sealed record LessThanOrEqualToMetadata(T ComparativeValue); +public sealed record EqualToMetadata(T ComparativeValue); +public sealed record NotEqualToMetadata(T ComparativeValue); +public sealed record InRangeMetadata(T LowerBoundary, T UpperBoundary); +public sealed record NotInRangeMetadata(T LowerBoundary, T UpperBoundary); +public sealed record ExclusiveRangeMetadata(T LowerBoundary, T UpperBoundary); +``` + +The builder extensions wrap the existing inline `WithErrorMetadata(string code)` escape hatch so the transformer needs no new code path: + +```csharp +public static class BuiltInValidationErrorBuilderExtensions +{ + public static PortableValidationProblemOpenApiBuilder WithInRangeError( + this PortableValidationProblemOpenApiBuilder builder) => + builder.WithErrorMetadata>(ValidationErrorCodes.InRange); + + public static PortableProblemOpenApiBuilder WithInRangeError( + this PortableProblemOpenApiBuilder builder) => + builder.WithErrorMetadata>(ValidationErrorCodes.InRange); + + // ...the equality, greater/less, not-in-range, and exclusive-range variants follow the same shape. +} ``` -`MetadataPrimitiveValue` is registered alongside the per-code schemas and reused wherever a typed primitive metadata value appears. +Each helper is shipped on both the problem builder and the validation-problem builder so the same narrowing works whether the endpoint emits `application/problem+json` as a generic problem or as a validation problem. + +The endpoint from the design discussion becomes: + +```csharp +app.MapPost("/movies/{id}/ratings", ...) + .WithName("CreateRating") + .ProducesPortableValidationProblem(b => b + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) + .WithInRangeError()); +``` + +Two of the three codes reuse global `PortableError__` components (no duplication across endpoints) and the genuinely site-specific one becomes an operation-scoped `PortableError__CreateRating__400__application_problem_json__InRange` with `lowerBoundary: integer` and `upperBoundary: integer`. This is the same component-id and discriminator-mapping path the existing inline escape hatch already produces; the typed helpers only save the consumer from defining and naming a CLR DTO. + +These nine polymorphic codes are the only ones that get this treatment. Every other built-in code is shape-fixed (lengths/counts → integer, regex → string + integer, enum → string + boolean, precision/scale → integer + integer + boolean), so the global catalog schema is always exact and no narrowing helpers are needed. ### Package Wiring -`Light.PortableResults.Validation` takes on a `Microsoft.OpenApi` package reference to author `OpenApiSchema` instances. The reference is compile-time only; the validation package does not reference `Microsoft.AspNetCore.OpenApi` or `Light.PortableResults.AspNetCore.OpenApi` and remains usable from non-ASP.NET Core hosts — `RegisterBuiltInValidationErrors` is an extension on `PortableErrorMetadataContractsBuilder` (declared in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`), so callers who pull in the validation package without the OpenAPI package simply never see the extension in scope. The `RegisterBuiltInValidationErrors` extension method lives in a file under the same namespace as `BuiltInValidationErrorContracts` so a single `using` import exposes the opt-in along with the constants. +`Light.PortableResults.Validation.OpenApi` takes on the `Microsoft.OpenApi` package reference (or inherits it transitively from `Light.PortableResults.AspNetCore.OpenApi`) to author `OpenApiSchema` instances. The reference does not flow back into `Light.PortableResults.Validation`, which remains usable from non-ASP.NET-Core hosts. `RegisterBuiltInValidationErrors` is an extension on `PortableErrorMetadataContractsBuilder` (declared in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`), so callers who pull in only `Light.PortableResults.Validation` without the bridge package never see the extension in scope and never pay the OpenAPI cost. Callers who want the built-in contracts add a single project/package reference to `Light.PortableResults.Validation.OpenApi` and the extension lights up via a single `using Light.PortableResults.Validation.OpenApi;`. ### Scope Boundaries - This plan does not auto-register the built-in contracts from `AddPortableResultsForMinimalApis` / `AddPortableResultsForMvc`. The opt-in is explicit so that consumers who do not use the validation package, or who use custom message templates with bespoke codes, are not forced into an extra contract catalog. - This plan does not unify `IPortableErrorMetadataContractRegistry` with `IValidationErrorDefinitionCache`. They cover different concerns (documentation vs runtime messaging) and any unification is a separate refactor. -- This plan does not ship CLR DTO types that mirror the built-in metadata shapes. Pre-authored `OpenApiSchema` instances are the canonical representation for these polymorphic contracts. +- This plan does not make `PortableErrorMetadataContract` user-extensible. The contract hierarchy is intentionally closed and library-owned; callers can register metadata by CLR type, schema factory, or no-metadata marker through the builder APIs. If future scenarios require fully custom contract behavior, that should be designed as a separate behavior-based extension point rather than by allowing arbitrary subclasses. +- This plan does not ship CLR DTO types that mirror the built-in metadata shapes for the global catalog. Per-code `Func` factories are the canonical representation for the polymorphic global contracts. The nine generic CLR records added in `Light.PortableResults.Validation.OpenApi` (`EqualToMetadata`, `GreaterThanMetadata`, `InRangeMetadata`, etc.) are scoped to the per-endpoint typed-narrowing helpers and are not used by the global catalog. +- This plan does not derive endpoint OpenAPI contracts from validator types directly (e.g. `ProducesPortableValidationProblemFor()`). The current `Validator` model uses an imperative `PerformValidation` body, and there is no statically discoverable manifest of "which codes with which `T`s." Reaching that ergonomy would require either lifting validators to a declarative form or recording calls under a synthetic context at startup; both are larger refactors and out of scope for this plan. +- This plan does not register `Predicate` in the built-in catalog. `Predicate` is the default code emitted by `Must(...)` overloads (`Checks.Predicate.cs`), which routinely accept caller-supplied `ValidationErrorDefinition` instances with bespoke metadata shapes; a globally registered no-metadata contract for `Predicate` would lock the schema for those flows. Consumers who want to document a `Predicate` flow either register their own contract (typically under a custom code attached to a custom `ValidationErrorDefinition`) or use the inline `WithErrorMetadata` escape hatch on the relevant endpoint. From cdb2fa4414b6f5ce077982337f90bfc5df0e7b8c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 18:45:27 +0200 Subject: [PATCH 40/67] feat: implement built-in OpenAPI error contracts Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + README.md | 50 ++- ai-plans/0040-2-validation-error-contracts.md | 32 +- .../GetMovies/GetMoviesEndpoint.cs | 10 +- .../NativeAotMovieRating.csproj | 1 + .../NewMovie/AddNewMovieEndpoint.cs | 9 +- .../NewMovieRating/NewMovieRatingEndpoint.cs | 15 +- samples/NativeAotMovieRating/Program.cs | 2 + .../NativeAotMovieRating/packages.lock.json | 7 + .../IPortableErrorMetadataContractRegistry.cs | 5 +- .../PortableErrorMetadataContract.cs | 91 ++++ ...leErrorMetadataContractEqualityComparer.cs | 44 ++ .../PortableErrorMetadataContractRegistry.cs | 18 +- .../PortableErrorMetadataContractsBuilder.cs | 41 +- ...rtableResultsOpenApiDocumentTransformer.cs | 97 +++-- .../PortableResultsOpenApiMessages.cs | 20 + ...BuiltInValidationErrorBuilderExtensions.cs | 126 ++++++ ...tionErrorContractRegistrationExtensions.cs | 44 ++ .../BuiltInValidationErrorContracts.cs | 164 ++++++++ .../BuiltInValidationErrorMetadata.cs | 51 +++ ....PortableResults.Validation.OpenApi.csproj | 22 + .../packages.lock.json | 117 ++++++ ...InValidationErrorDefinitions.Comparable.cs | 14 +- ...BuiltInValidationErrorDefinitions.Count.cs | 6 +- ...ltInValidationErrorDefinitions.Decimals.cs | 2 +- ...BuiltInValidationErrorDefinitions.Empty.cs | 4 +- ...BuiltInValidationErrorDefinitions.Enums.cs | 4 +- ...ltInValidationErrorDefinitions.Equality.cs | 4 +- .../BuiltInValidationErrorDefinitions.Null.cs | 4 +- ...tInValidationErrorDefinitions.Predicate.cs | 2 +- ...iltInValidationErrorDefinitions.Strings.cs | 16 +- .../ValidationErrorCodes.cs | 64 +++ .../BuiltInValidationErrorContractsTests.cs | 202 +++++++++ ...bleResults.AspNetCore.OpenApi.Tests.csproj | 1 + .../PortableErrorMetadataContractTests.cs | 113 +++++ ...lidationOpenApiDocumentTransformerTests.cs | 396 ++++++++++++++++++ .../packages.lock.json | 13 + .../BuiltInRuleFamilyWorkflowTests.cs | 6 +- .../CheckOverloadCoverageTests.cs | 6 +- .../CheckShortCircuitCoverageTests.cs | 4 +- .../ErrorOverridesTests.cs | 4 +- .../ValidationErrorDefinitionTests.cs | 24 +- 42 files changed, 1737 insertions(+), 119 deletions(-) create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj create mode 100644 src/Light.PortableResults.Validation.OpenApi/packages.lock.json create mode 100644 src/Light.PortableResults.Validation/ValidationErrorCodes.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 785dec0..7f4c84f 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -81,6 +81,7 @@ + diff --git a/README.md b/README.md index 6db91bf..86bc9dc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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": { @@ -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": { @@ -1324,17 +1336,19 @@ services ## OpenAPI Support -OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult` / `LightActionResult` still serialize through the JSON writers in `Light.PortableResults`; the OpenAPI package only contributes endpoint metadata plus a document transformer. +OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult` / `LightActionResult` 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 .AddPortableResultsForMinimalApis() .AddPortableResultsOpenApi() + .ConfigureErrorMetadataContracts(contracts => contracts.RegisterBuiltInValidationErrors()) .AddOpenApi(); ``` @@ -1369,6 +1383,34 @@ PortableResults OpenAPI metadata is authoritative for a given `(status code, con Top-level metadata and per-error-code metadata are caller-owned contracts. The OpenAPI package documents them explicitly; the runtime still writes `MetadataObject`. +For built-in validation errors, reference `Light.PortableResults.Validation.OpenApi` and register the catalog once: + +```csharp +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation.OpenApi; + +builder.Services.ConfigureErrorMetadataContracts( + 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() + ); +``` + +Comparison and range codes are polymorphic at the global code level, so the validation bridge also ships typed endpoint helpers: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. 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: ```csharp @@ -1381,6 +1423,8 @@ builder.Services.ConfigureErrorMetadataContracts(contracts => }); ``` +User-defined codes continue to use the type-based overloads above, or endpoint-scoped `WithErrorMetadata(code)` when a contract only applies to one operation. + Then opt the relevant codes into each endpoint: ```csharp diff --git a/ai-plans/0040-2-validation-error-contracts.md b/ai-plans/0040-2-validation-error-contracts.md index b861564..dadf9de 100644 --- a/ai-plans/0040-2-validation-error-contracts.md +++ b/ai-plans/0040-2-validation-error-contracts.md @@ -16,26 +16,26 @@ This plan widens the registry to also accept pre-authored `OpenApiSchema` values ## Acceptance Criteria -- [ ] `PortableErrorMetadataContract` is introduced as a public abstract base class with a library-owned closed set of sealed subclasses in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of (a) a CLR `Type` (to be run through the ASP.NET Core schema generator), (b) a per-code `Func` factory, or (c) the absence of metadata. The base type is `public abstract class PortableErrorMetadataContract` exposing `static FromType(Type metadataType)` and `static FromSchema(Func schemaFactory)` factory methods and a `static PortableErrorMetadataContract NoMetadata { get; }` singleton. No public `Kind` enum is exposed; the concrete subclass is the discriminator and the transformer dispatches via pattern matching. Three sealed subclasses `PortableErrorMetadataTypeContract`, `PortableErrorMetadataSchemaContract`, and `PortableErrorMetadataNoMetadataContract` carry the respective payloads; the type and factory subclasses expose their payloads as public read-only properties. The factory shape (rather than a static `OpenApiSchema` instance) is mandatory because `OpenApiSchema` is a mutable POCO; storing a single instance in a static catalog leaks mutations across consumer hosts. The factory accepts the resolved `OpenApiSpecVersion` so spec-version-dependent shapes can author themselves correctly; callers that do not need the version simply ignore the parameter. OpenAPI document generation runs only during application startup, so neither the factory invocation nor the abstract-class allocation is perf-sensitive. -- [ ] `IPortableErrorMetadataContractRegistry.Contracts` is widened from `IReadOnlyDictionary` to `IReadOnlyDictionary`. The default implementation and its tests are updated accordingly. -- [ ] `PortableErrorMetadataContractsBuilder` gains two new overloads: `ForCode(string code, Func metadataSchemaFactory)` (storing `PortableErrorMetadataContract.FromSchema(...)`) and `ForCode(string code)` (storing `PortableErrorMetadataContract.NoMetadata` for codes that the runtime never decorates with metadata). The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. Re-registering the same code with an equivalent contract is idempotent; registering the same code with a different contract throws a clear duplicate-contract exception instead of using last-writer-wins. -- [ ] `PortableResultsOpenApiDocumentTransformer` in `Light.PortableResults.AspNetCore.OpenApi.Generation` is updated to dispatch on the concrete subclass when materializing registry entries: `PortableErrorMetadataTypeContract` entries go through the ASP.NET Core schema generator as before; `PortableErrorMetadataSchemaContract` entries invoke the factory once per generated metadata component (passing the resolved `OpenApiSpecVersion`), install the produced schema, and `$ref` it from the narrowed code schema; `PortableErrorMetadataNoMetadataContract` entries emit the narrowed envelope without a `metadata` reference at all. Concretely, for type and schema contracts the synthesized extension schema continues to be `{ properties: { code: const, metadata: $ref }, required: [code] }`; for no-metadata contracts the extension schema is `{ properties: { code: const }, required: [code] }`, which leaves `metadata` to inherit from the base schema (open object, nullable). This is faithful to the wire — the runtime simply does not write a `metadata` property for these codes — and matches the canonical envelope's nullability. Schema-based contracts therefore only replace the `metadata` reference target, no-metadata contracts remove it entirely, and the narrowed-envelope `allOf [base, extension]` construction in `CreateCodeSpecificSchema` is otherwise unchanged. All three contract kinds share one component-id namespace. -- [ ] Schema-based metadata components are stored under the same naming convention used for type-based metadata: `____Metadata` (for example `PortableError__Count__Metadata`), produced by the existing `PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(...)` helper. There is no flat `Metadata` namespace; both contract kinds live in the same component-id space so tools that walk `Components.Schemas` see one rule rather than two. -- [ ] A new project `Light.PortableResults.Validation.OpenApi` is added to the solution. It targets .NET 10, sets `true`, and project-references both `Light.PortableResults.Validation` and `Light.PortableResults.AspNetCore.OpenApi`. `Light.PortableResults.Validation` itself does **not** gain a `Microsoft.OpenApi` reference and remains OpenAPI-agnostic so non-ASP.NET-Core hosts (messaging, gRPC, console) carry no transitive OpenAPI dependency. -- [ ] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation.OpenApi` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains one entry per built-in validation error code that has a stable framework-level shape: +- [x] `PortableErrorMetadataContract` is introduced as a public abstract base class with a library-owned closed set of sealed subclasses in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of (a) a CLR `Type` (to be run through the ASP.NET Core schema generator), (b) a per-code `Func` factory, or (c) the absence of metadata. The base type is `public abstract class PortableErrorMetadataContract` exposing `static FromType(Type metadataType)` and `static FromSchema(Func schemaFactory)` factory methods and a `static PortableErrorMetadataContract NoMetadata { get; }` singleton. No public `Kind` enum is exposed; the concrete subclass is the discriminator and the transformer dispatches via pattern matching. Three sealed subclasses `PortableErrorMetadataTypeContract`, `PortableErrorMetadataSchemaContract`, and `PortableErrorMetadataNoMetadataContract` carry the respective payloads; the type and factory subclasses expose their payloads as public read-only properties. The factory shape (rather than a static `OpenApiSchema` instance) is mandatory because `OpenApiSchema` is a mutable POCO; storing a single instance in a static catalog leaks mutations across consumer hosts. The factory accepts the resolved `OpenApiSpecVersion` so spec-version-dependent shapes can author themselves correctly; callers that do not need the version simply ignore the parameter. OpenAPI document generation runs only during application startup, so neither the factory invocation nor the abstract-class allocation is perf-sensitive. +- [x] `IPortableErrorMetadataContractRegistry.Contracts` is widened from `IReadOnlyDictionary` to `IReadOnlyDictionary`. The default implementation and its tests are updated accordingly. +- [x] `PortableErrorMetadataContractsBuilder` gains two new overloads: `ForCode(string code, Func metadataSchemaFactory)` (storing `PortableErrorMetadataContract.FromSchema(...)`) and `ForCode(string code)` (storing `PortableErrorMetadataContract.NoMetadata` for codes that the runtime never decorates with metadata). The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. Re-registering the same code with an equivalent contract is idempotent; registering the same code with a different contract throws a clear duplicate-contract exception instead of using last-writer-wins. +- [x] `PortableResultsOpenApiDocumentTransformer` in `Light.PortableResults.AspNetCore.OpenApi.Generation` is updated to dispatch on the concrete subclass when materializing registry entries: `PortableErrorMetadataTypeContract` entries go through the ASP.NET Core schema generator as before; `PortableErrorMetadataSchemaContract` entries invoke the factory once per generated metadata component (passing the resolved `OpenApiSpecVersion`), install the produced schema, and `$ref` it from the narrowed code schema; `PortableErrorMetadataNoMetadataContract` entries emit the narrowed envelope without a `metadata` reference at all. Concretely, for type and schema contracts the synthesized extension schema continues to be `{ properties: { code: const, metadata: $ref }, required: [code] }`; for no-metadata contracts the extension schema is `{ properties: { code: const }, required: [code] }`, which leaves `metadata` to inherit from the base schema (open object, nullable). This is faithful to the wire — the runtime simply does not write a `metadata` property for these codes — and matches the canonical envelope's nullability. Schema-based contracts therefore only replace the `metadata` reference target, no-metadata contracts remove it entirely, and the narrowed-envelope `allOf [base, extension]` construction in `CreateCodeSpecificSchema` is otherwise unchanged. All three contract kinds share one component-id namespace. +- [x] Schema-based metadata components are stored under the same naming convention used for type-based metadata: `____Metadata` (for example `PortableError__Count__Metadata`), produced by the existing `PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(...)` helper. There is no flat `Metadata` namespace; both contract kinds live in the same component-id space so tools that walk `Components.Schemas` see one rule rather than two. +- [x] A new project `Light.PortableResults.Validation.OpenApi` is added to the solution. It targets .NET 10, sets `true`, and project-references both `Light.PortableResults.Validation` and `Light.PortableResults.AspNetCore.OpenApi`. `Light.PortableResults.Validation` itself does **not** gain a `Microsoft.OpenApi` reference and remains OpenAPI-agnostic so non-ASP.NET-Core hosts (messaging, gRPC, console) carry no transitive OpenAPI dependency. +- [x] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation.OpenApi` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains one entry per built-in validation error code that has a stable framework-level shape: - Codes that carry metadata (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`) are stored as `PortableErrorMetadataSchemaContract` instances whose factory returns a fresh `OpenApiSchema` on each invocation, using the exact JSON property names defined in `ValidationErrorMetadataKeys`. - Codes that the framework guarantees emit no metadata (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`) are stored as `PortableErrorMetadataContract.NoMetadata`. These are included so consumers can opt them into endpoints via `WithErrorCodes` without falling back to the inline `WithErrorMetadata` escape hatch with a synthetic empty type. - `Predicate` is intentionally excluded because it is the default code emitted by `Must(...)` overloads (`Checks.Predicate.cs`), which routinely accept caller-supplied `ValidationErrorDefinition` instances with bespoke metadata shapes. A globally registered no-metadata contract for `Predicate` would lock the schema for those flows and conflict with what consumers actually want to document. -- [ ] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives. The exact branches depend on the resolved `OpenApiSpecVersion` of the document, mirroring the existing `const` vs `enum` branch in the transformer: +- [x] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives. The exact branches depend on the resolved `OpenApiSpecVersion` of the document, mirroring the existing `const` vs `enum` branch in the transformer: - OpenAPI 3.1+: `oneOf: [{ type: string }, { type: number }, { type: integer }, { type: boolean }, { type: "null" }]`. - OpenAPI 3.0: `oneOf: [{ type: string }, { type: number }, { type: integer }, { type: boolean }]` plus `nullable: true` on the parent property; the `null` branch is omitted because OpenAPI 3.0 does not support `type: "null"`. -- [ ] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`, `NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`, `Predicate`). The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. Because the library is pre-stable, this plan also improves the current runtime code strings for developer experience: `LengthIn` becomes `LengthInRange`, `Matches` becomes `Pattern`, `IsInBetween` becomes `InRange`, and `NotInBetween` becomes `NotInRange`. `ValidationErrorCodes` stays in `Light.PortableResults.Validation` (not the new bridge package) because the constants are independently useful in switch arms, message templates, and inline error metadata even when the OpenAPI package is not referenced. -- [ ] A public extension method `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder builder)` is added in `Light.PortableResults.Validation.OpenApi`. It iterates `BuiltInValidationErrorContracts.Contracts` and registers each entry into the builder by dispatching on the contract subclass: schema entries call the factory overload, no-metadata entries call `ForCode(string)`, and any future type entries would call the existing `ForCode(string, Type)` overload. `Predicate` is intentionally not registered for the reason described above; consumers who want to document a `Predicate` flow either supply their own `ValidationErrorDefinition` with a custom code and register that, or use the inline `WithErrorMetadata` escape hatch on the relevant endpoint. -- [ ] Nine generic CLR record types are added to `Light.PortableResults.Validation.OpenApi` to back per-endpoint narrowing of the polymorphic comparison and range codes: `EqualToMetadata(T ComparativeValue)`, `NotEqualToMetadata(T ComparativeValue)`, `GreaterThanMetadata(T ComparativeValue)`, `GreaterThanOrEqualToMetadata(T ComparativeValue)`, `LessThanMetadata(T ComparativeValue)`, `LessThanOrEqualToMetadata(T ComparativeValue)`, `InRangeMetadata(T LowerBoundary, T UpperBoundary)`, `NotInRangeMetadata(T LowerBoundary, T UpperBoundary)`, and `ExclusiveRangeMetadata(T LowerBoundary, T UpperBoundary)`. These are the only built-in codes whose metadata genuinely varies in `T` across call sites (every other built-in code is shape-fixed: lengths/counts → integer, regex → string + integer, enum → string + boolean, precision/scale → integer + integer + boolean). Property names match `ValidationErrorMetadataKeys` exactly so the schema generator's casing convention produces the wire-correct keys (`comparativeValue`, `lowerBoundary`, `upperBoundary`). -- [ ] A static class `BuiltInValidationErrorBuilderExtensions` in `Light.PortableResults.Validation.OpenApi` exposes typed extension methods on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. Each helper is a thin wrapper over the existing inline escape hatch — for example `WithInRangeError()` calls `WithErrorMetadata>(ValidationErrorCodes.InRange)` — so the transformer needs no new code path, the endpoint-scoped component id naming (`PortableError________InRange`) is reused, and the resulting schema for the typed bound (`integer` for `T = int`, `string` with `format: date-time` for `T = DateTime`, etc.) comes out of the standard ASP.NET Core schema generator. Endpoints that mix global and narrowed contracts (e.g. `WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()`) get the global-component reuse for the polymorphism-free codes and the operation-scoped narrowed component for `InRange`, in the same discriminated `anyOf`. -- [ ] `Light.PortableResults.Validation.OpenApi.csproj` adds a package reference to `Microsoft.OpenApi` if not already supplied transitively through `Light.PortableResults.AspNetCore.OpenApi`, and a corresponding `` entry is added to `Directory.Packages.props` if missing. `Light.PortableResults.Validation.csproj` is unchanged on the dependency front. -- [ ] The `NativeAotMovieRating` sample is updated to reference `Light.PortableResults.Validation.OpenApi`, call `.RegisterBuiltInValidationErrors()` inside `ConfigureErrorMetadataContracts`, and opt its endpoints into the relevant built-in codes via `WithErrorCodes(ValidationErrorCodes.Count, ...)`. At least one endpoint demonstrates a narrowed comparison helper (e.g. `.WithInRangeError()` for the rating endpoint that uses `IsInBetween(1, 5)`) so the sample documents the recommended path for site-specific narrowing of polymorphic comparison codes. -- [ ] Automated tests cover: +- [x] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`, `NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`, `Predicate`). The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. Because the library is pre-stable, this plan also improves the current runtime code strings for developer experience: `LengthIn` becomes `LengthInRange`, `Matches` becomes `Pattern`, `IsInBetween` becomes `InRange`, and `NotInBetween` becomes `NotInRange`. `ValidationErrorCodes` stays in `Light.PortableResults.Validation` (not the new bridge package) because the constants are independently useful in switch arms, message templates, and inline error metadata even when the OpenAPI package is not referenced. +- [x] A public extension method `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder builder)` is added in `Light.PortableResults.Validation.OpenApi`. It iterates `BuiltInValidationErrorContracts.Contracts` and registers each entry into the builder by dispatching on the contract subclass: schema entries call the factory overload, no-metadata entries call `ForCode(string)`, and any future type entries would call the existing `ForCode(string, Type)` overload. `Predicate` is intentionally not registered for the reason described above; consumers who want to document a `Predicate` flow either supply their own `ValidationErrorDefinition` with a custom code and register that, or use the inline `WithErrorMetadata` escape hatch on the relevant endpoint. +- [x] Nine generic CLR record types are added to `Light.PortableResults.Validation.OpenApi` to back per-endpoint narrowing of the polymorphic comparison and range codes: `EqualToMetadata(T ComparativeValue)`, `NotEqualToMetadata(T ComparativeValue)`, `GreaterThanMetadata(T ComparativeValue)`, `GreaterThanOrEqualToMetadata(T ComparativeValue)`, `LessThanMetadata(T ComparativeValue)`, `LessThanOrEqualToMetadata(T ComparativeValue)`, `InRangeMetadata(T LowerBoundary, T UpperBoundary)`, `NotInRangeMetadata(T LowerBoundary, T UpperBoundary)`, and `ExclusiveRangeMetadata(T LowerBoundary, T UpperBoundary)`. These are the only built-in codes whose metadata genuinely varies in `T` across call sites (every other built-in code is shape-fixed: lengths/counts → integer, regex → string + integer, enum → string + boolean, precision/scale → integer + integer + boolean). Property names match `ValidationErrorMetadataKeys` exactly so the schema generator's casing convention produces the wire-correct keys (`comparativeValue`, `lowerBoundary`, `upperBoundary`). +- [x] A static class `BuiltInValidationErrorBuilderExtensions` in `Light.PortableResults.Validation.OpenApi` exposes typed extension methods on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. Each helper is a thin wrapper over the existing inline escape hatch — for example `WithInRangeError()` calls `WithErrorMetadata>(ValidationErrorCodes.InRange)` — so the transformer needs no new code path, the endpoint-scoped component id naming (`PortableError________InRange`) is reused, and the resulting schema for the typed bound (`integer` for `T = int`, `string` with `format: date-time` for `T = DateTime`, etc.) comes out of the standard ASP.NET Core schema generator. Endpoints that mix global and narrowed contracts (e.g. `WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()`) get the global-component reuse for the polymorphism-free codes and the operation-scoped narrowed component for `InRange`, in the same discriminated `anyOf`. +- [x] `Light.PortableResults.Validation.OpenApi.csproj` adds a package reference to `Microsoft.OpenApi` if not already supplied transitively through `Light.PortableResults.AspNetCore.OpenApi`, and a corresponding `` entry is added to `Directory.Packages.props` if missing. `Light.PortableResults.Validation.csproj` is unchanged on the dependency front. +- [x] The `NativeAotMovieRating` sample is updated to reference `Light.PortableResults.Validation.OpenApi`, call `.RegisterBuiltInValidationErrors()` inside `ConfigureErrorMetadataContracts`, and opt its endpoints into the relevant built-in codes via `WithErrorCodes(ValidationErrorCodes.Count, ...)`. At least one endpoint demonstrates a narrowed comparison helper (e.g. `.WithInRangeError()` for the rating endpoint that uses `IsInBetween(1, 5)`) so the sample documents the recommended path for site-specific narrowing of polymorphic comparison codes. +- [x] Automated tests cover: - The discriminated-union behavior of `PortableErrorMetadataContract` (factory methods, `NoMetadata` singleton, sealed-subclass payloads, pattern-match scenarios, and no public `Kind` enum). - The schema output for every metadata-bearing built-in code (round-tripped against the taxonomy in `ValidationErrorMetadataKeys`). - Duplicate-registration behavior: equivalent repeated registrations are idempotent, while conflicting registrations for the same raw code throw a clear error and do not silently use last-writer-wins. @@ -48,7 +48,7 @@ This plan widens the registry to also accept pre-authored `OpenApiSchema` values - The typed comparison and range helpers: `WithInRangeError()` produces an endpoint-scoped variant whose metadata schema is `{ lowerBoundary: integer, upperBoundary: integer, required: [lowerBoundary, upperBoundary] }`, and `WithInRangeError()` produces `{ type: string, format: date-time }` for both bounds. Equivalent assertions for the equality, greater/less, not-in-range, and exclusive-range helpers. - A mixed-contract endpoint scenario that mirrors the validator example in the design discussion (`IsNotEmpty`, `HasLengthIn(10, 1000)`, `IsInBetween(1, 5)`): registering the validator endpoint with `.WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()` produces a discriminated `anyOf` whose `NotEmpty` and `LengthInRange` branches reference the global `PortableError__` components and whose `InRange` branch references the endpoint-scoped narrowed component. - An end-to-end scenario where an endpoint opts into a metadata-bearing built-in code and a no-metadata built-in code (e.g. `Count` and `NotNull`) and the generated OpenAPI document contains the expected narrowed schemas in the discriminated `anyOf`. -- [ ] `README.md` is updated to describe the new `Light.PortableResults.Validation.OpenApi` package and its opt-in one-liner, the built-in taxonomy surfaced by `ValidationErrorCodes`, the typed comparison/range helpers (`WithInRangeError`, `WithGreaterThanError`, `WithEqualToError`, etc.) for site-specific narrowing of polymorphic codes, and the fact that user-defined codes continue to register through the existing type-based overloads on the OpenAPI package. +- [x] `README.md` is updated to describe the new `Light.PortableResults.Validation.OpenApi` package and its opt-in one-liner, the built-in taxonomy surfaced by `ValidationErrorCodes`, the typed comparison/range helpers (`WithInRangeError`, `WithGreaterThanError`, `WithEqualToError`, etc.) for site-specific narrowing of polymorphic codes, and the fact that user-defined codes continue to register through the existing type-based overloads on the OpenAPI package. ## Technical Details diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index 19b874b..d4115a1 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -8,6 +8,7 @@ using Light.PortableResults.Http.Writing; using Light.PortableResults.Metadata; using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using NativeAotMovieRating.InMemoryDatabaseAccess; @@ -26,9 +27,14 @@ public static void MapGetMoviesEndpoint(this WebApplication app) => "Supports keyset pagination via the optional lastKnownMovieId parameter and a configurable " + "page size via the take parameter (1-40). Returns a rich Light.PortableResults problem " + "details response when input validation fails or when the lastKnownMovieId does not exist." - ) + ) .Produces>() - .ProducesPortableValidationProblem(configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)); + .ProducesPortableValidationProblem( + configure: x => x + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes(ValidationErrorCodes.NotEmpty) + .WithInRangeError() + ); private static async Task GetMovies( IGetMoviesSession session, diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index adedf7f..76f0450 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -14,6 +14,7 @@ + diff --git a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs index 28317fe..13b5a0c 100644 --- a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs @@ -3,6 +3,7 @@ using Light.PortableResults.AspNetCore.MinimalApis; using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Http.Writing; +using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -18,9 +19,13 @@ public static void MapNewMovieEndpoint(this WebApplication app) => .WithSummary("Adds a new movie.") .WithDescription( "Validates the request and stores a new movie. Returns the stored movie on success, or a rich Light.PortableResults problem details response on validation failures." - ) + ) .Produces() - .ProducesPortableValidationProblem(configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)) + .ProducesPortableValidationProblem( + configure: x => x + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.NotNullOrWhiteSpace) + ) .ProducesPortableProblem(); private static async Task NewMovieRating( diff --git a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs index 55e7f0d..8b3a7a9 100644 --- a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs @@ -3,6 +3,8 @@ using Light.PortableResults.AspNetCore.MinimalApis; using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Http.Writing; +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using NativeAotMovieRating.InMemoryDatabaseAccess; @@ -19,9 +21,18 @@ public static void MapAddMovieRatingEndpoint(this WebApplication app) => .WithSummary("Adds or updates a movie rating.") .WithDescription( "Validates the request and stores the movie rating. Returns the stored rating on success, or a rich Light.PortableResults problem details response on validation or lookup failures." - ) + ) .Produces() - .ProducesPortableValidationProblem(configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)) + .ProducesPortableValidationProblem( + configure: x => x + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes( + ValidationErrorCodes.NotEmpty, + ValidationErrorCodes.LengthInRange, + ValidationErrorCodes.NotNullOrWhiteSpace + ) + .WithInRangeError() + ) .ProducesPortableProblem(); private static async Task AddMovieRating( diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index cf453bd..41db9af 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -1,6 +1,7 @@ using Light.PortableResults.AspNetCore.MinimalApis; using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using NativeAotMovieRating.GetMovies; @@ -22,6 +23,7 @@ .Services .AddPortableResultsForMinimalApis() .AddPortableResultsOpenApi() + .ConfigureErrorMetadataContracts(contracts => contracts.RegisterBuiltInValidationErrors()) .AddValidationForPortableResults() .ConfigureJsonSerialization() .AddInMemoryDatabase() diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index cfb896b..97ef157 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -163,6 +163,13 @@ "Light.PortableResults": "[0.4.0, )" } }, + "light.portableresults.validation.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.OpenApi": "[0.4.0, )", + "Light.PortableResults.Validation": "[0.4.0, )" + } + }, "Microsoft.Bcl.HashCode": { "type": "CentralTransitive", "requested": "[6.0.0, )", diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs index b190036..ea648e2 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -9,7 +8,7 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; public interface IPortableErrorMetadataContractRegistry { /// - /// Gets the immutable map of documented error codes to their metadata CLR types. + /// Gets the immutable map of documented error codes to their metadata contracts. /// - IReadOnlyDictionary Contracts { get; } + IReadOnlyDictionary Contracts { get; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs new file mode 100644 index 0000000..2701a89 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Represents a documented metadata contract for a portable error code. +/// +public abstract class PortableErrorMetadataContract +{ + private static readonly PortableErrorMetadataNoMetadataContract SharedNoMetadata = new (); + + private protected PortableErrorMetadataContract() { } + + /// + /// Gets the singleton contract for error codes that do not emit metadata. + /// + public static PortableErrorMetadataContract NoMetadata => SharedNoMetadata; + + /// + /// Creates a contract backed by a CLR metadata type. + /// + /// The CLR metadata type. + /// The metadata contract. + public static PortableErrorMetadataContract FromType(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + return new PortableErrorMetadataTypeContract(metadataType); + } + + /// + /// Creates a contract backed by a schema factory. + /// + /// The factory that creates a fresh metadata schema for the requested OpenAPI version. + /// The metadata contract. + public static PortableErrorMetadataContract FromSchema(Func schemaFactory) + { + ArgumentNullException.ThrowIfNull(schemaFactory); + return new PortableErrorMetadataSchemaContract(schemaFactory); + } +} + +/// +/// Represents a metadata contract backed by a CLR type. +/// +public sealed class PortableErrorMetadataTypeContract : PortableErrorMetadataContract +{ + /// + /// Initializes a new instance of . + /// + /// The CLR metadata type. + public PortableErrorMetadataTypeContract(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + MetadataType = metadataType; + } + + /// + /// Gets the CLR metadata type. + /// + public Type MetadataType { get; } +} + +/// +/// Represents a metadata contract backed by an OpenAPI schema factory. +/// +public sealed class PortableErrorMetadataSchemaContract : PortableErrorMetadataContract +{ + /// + /// Initializes a new instance of . + /// + /// The factory that creates a fresh metadata schema for the requested OpenAPI version. + public PortableErrorMetadataSchemaContract(Func schemaFactory) + { + ArgumentNullException.ThrowIfNull(schemaFactory); + SchemaFactory = schemaFactory; + } + + /// + /// Gets the factory that creates a fresh metadata schema for the requested OpenAPI version. + /// + public Func SchemaFactory { get; } +} + +/// +/// Represents a metadata contract for error codes that do not emit metadata. +/// +public sealed class PortableErrorMetadataNoMetadataContract : PortableErrorMetadataContract +{ + internal PortableErrorMetadataNoMetadataContract() { } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs new file mode 100644 index 0000000..75f30ad --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +internal sealed class PortableErrorMetadataContractEqualityComparer : IEqualityComparer +{ + internal static PortableErrorMetadataContractEqualityComparer Instance { get; } = new (); + + private PortableErrorMetadataContractEqualityComparer() { } + + public bool Equals(PortableErrorMetadataContract? x, PortableErrorMetadataContract? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x switch + { + PortableErrorMetadataTypeContract xType when y is PortableErrorMetadataTypeContract yType => + xType.MetadataType == yType.MetadataType, + PortableErrorMetadataSchemaContract xSchema when y is PortableErrorMetadataSchemaContract ySchema => + ReferenceEquals(xSchema.SchemaFactory, ySchema.SchemaFactory), + PortableErrorMetadataNoMetadataContract when y is PortableErrorMetadataNoMetadataContract => true, + _ => false + }; + } + + public int GetHashCode(PortableErrorMetadataContract obj) + { + return obj switch + { + PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.GetHashCode(), + PortableErrorMetadataSchemaContract schemaContract => schemaContract.SchemaFactory.GetHashCode(), + PortableErrorMetadataNoMetadataContract => 0, + _ => obj.GetHashCode() + }; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs index a496c01..0356252 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs @@ -19,13 +19,13 @@ public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuild { ArgumentNullException.ThrowIfNull(builder); - var contracts = new Dictionary(StringComparer.Ordinal); + var contracts = new Dictionary(StringComparer.Ordinal); var sanitizedCodes = new Dictionary(StringComparer.Ordinal); - foreach (var (code, metadataType) in builder.Contracts) + foreach (var (code, contract) in builder.Contracts) { - if (contracts.TryGetValue(code, out var existingType)) + if (contracts.TryGetValue(code, out var existingContract)) { - if (existingType == metadataType) + if (PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, contract)) { continue; } @@ -33,8 +33,8 @@ public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuild throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( code, - existingType, - metadataType + existingContract, + contract ) ); } @@ -54,13 +54,13 @@ public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuild ); } - contracts.Add(code, metadataType); + contracts.Add(code, contract); sanitizedCodes.Add(sanitizedCode, code); } - Contracts = new ReadOnlyDictionary(contracts); + Contracts = new ReadOnlyDictionary(contracts); } /// - public IReadOnlyDictionary Contracts { get; } + public IReadOnlyDictionary Contracts { get; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs index bbe12ca..736050b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Light.PortableResults.AspNetCore.OpenApi.Generation; using Light.PortableResults.AspNetCore.OpenApi.Schemas; +using Microsoft.OpenApi; namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -10,10 +11,10 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// public sealed class PortableErrorMetadataContractsBuilder { - private readonly Dictionary _contracts = new (StringComparer.Ordinal); + private readonly Dictionary _contracts = new (StringComparer.Ordinal); private readonly Dictionary _sanitizedCodes = new (StringComparer.Ordinal); - internal IReadOnlyDictionary Contracts => _contracts; + internal IReadOnlyDictionary Contracts => _contracts; /// /// Registers as the metadata contract for the specified code. @@ -27,13 +28,37 @@ public PortableErrorMetadataContractsBuilder ForCode(string code) /// Registers the specified CLR metadata type for the specified code. /// public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataType) + { + return ForCode(code, PortableErrorMetadataContract.FromType(metadataType)); + } + + /// + /// Registers the specified OpenAPI metadata schema factory for the specified code. + /// + public PortableErrorMetadataContractsBuilder ForCode( + string code, + Func metadataSchemaFactory + ) + { + return ForCode(code, PortableErrorMetadataContract.FromSchema(metadataSchemaFactory)); + } + + /// + /// Registers the specified code as a code that emits no metadata. + /// + public PortableErrorMetadataContractsBuilder ForCode(string code) + { + return ForCode(code, PortableErrorMetadataContract.NoMetadata); + } + + internal PortableErrorMetadataContractsBuilder ForCode(string code, PortableErrorMetadataContract contract) { ArgumentException.ThrowIfNullOrWhiteSpace(code); - ArgumentNullException.ThrowIfNull(metadataType); + ArgumentNullException.ThrowIfNull(contract); - if (_contracts.TryGetValue(code, out var existingType)) + if (_contracts.TryGetValue(code, out var existingContract)) { - if (existingType == metadataType) + if (PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, contract)) { return this; } @@ -41,8 +66,8 @@ public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataT throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( code, - existingType, - metadataType + existingContract, + contract ) ); } @@ -59,7 +84,7 @@ public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataT ); } - _contracts.Add(code, metadataType); + _contracts.Add(code, contract); _sanitizedCodes.Add(sanitizedCode, code); return this; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 0a0ca75..ec8be4f 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -99,7 +99,7 @@ CancellationToken cancellationToken // documentation can later reference stable component ids instead of creating duplicates on demand. // Each contract produces two variants because regular problems use PortableError items while // ASP.NET-compatible validation problems use PortableValidationErrorDetail items. - foreach (var (errorCode, metadataType) in _errorMetadataContractRegistry.Contracts) + foreach (var (errorCode, contract) in _errorMetadataContractRegistry.Contracts) { var portableErrorSchemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId( PortableResultsOpenApiSchemas.PortableErrorSchemaId, @@ -111,7 +111,7 @@ await EnsureCodeSpecificSchemaAsync( PortableResultsOpenApiSchemas.PortableErrorSchemaId, portableErrorSchemaId, errorCode, - metadataType, + contract, openApiVersion, cancellationToken ); @@ -126,7 +126,7 @@ await EnsureCodeSpecificSchemaAsync( PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId, validationErrorSchemaId, errorCode, - metadataType, + contract, openApiVersion, cancellationToken ); @@ -390,19 +390,19 @@ CancellationToken cancellationToken // Collect the narrowed variants we can document for this error item schema, while tracking the // raw error codes we have already seen so we can reject conflicting metadata contracts. var documentedVariants = new List(); - var rawCodeTypes = new Dictionary(StringComparer.Ordinal); + var rawCodeContracts = new Dictionary(StringComparer.Ordinal); var inlineSanitizedCodes = new Dictionary(StringComparer.Ordinal); // Global error codes reuse pre-registered component schemas created from the application's // error-contract registry, so here we only validate the code and reference the stable component id. foreach (var code in attribute.ErrorCodes ?? []) { - if (!_errorMetadataContractRegistry.Contracts.TryGetValue(code, out var metadataType)) + if (!_errorMetadataContractRegistry.Contracts.TryGetValue(code, out var contract)) { throw new InvalidOperationException(PortableResultsOpenApiMessages.CreateUnknownErrorCodeMessage(code)); } - AddDocumentedCode(rawCodeTypes, code, metadataType); + AddDocumentedCode(rawCodeContracts, code, contract); var schemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId(itemBaseSchemaId, code); documentedVariants.Add( new DocumentedErrorVariant( @@ -436,15 +436,16 @@ CancellationToken cancellationToken ); } - if (rawCodeTypes.TryGetValue(code, out var existingType)) + var metadataTypeContract = PortableErrorMetadataContract.FromType(metadataType); + if (rawCodeContracts.TryGetValue(code, out var existingContract)) { - if (existingType != metadataType) + if (!PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, metadataTypeContract)) { throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( code, - existingType, - metadataType + existingContract, + metadataTypeContract ) ); } @@ -452,7 +453,7 @@ CancellationToken cancellationToken continue; } - rawCodeTypes.Add(code, metadataType); + rawCodeContracts.Add(code, metadataTypeContract); inlineSanitizedCodes.TryAdd(sanitizedCode, code); var schemaId = PortableResultsOpenApiSchemaNaming.CreateInlineErrorSchemaId( itemBaseSchemaId, @@ -468,7 +469,7 @@ CancellationToken cancellationToken itemBaseSchemaId, schemaId, code, - metadataType, + metadataTypeContract, openApiVersion, cancellationToken ); @@ -496,6 +497,7 @@ CancellationToken cancellationToken return new OpenApiSchema { AnyOf = anyOfSchemas, + Required = new HashSet(StringComparer.Ordinal) { "code" }, Discriminator = new OpenApiDiscriminator { PropertyName = "code", @@ -510,7 +512,7 @@ private async Task EnsureCodeSpecificSchemaAsync( string baseSchemaId, string schemaId, string errorCode, - Type metadataType, + PortableErrorMetadataContract contract, OpenApiSpecVersion openApiVersion, CancellationToken cancellationToken ) @@ -518,11 +520,12 @@ CancellationToken cancellationToken var schemas = EnsureSchemaStore(document); if (!schemas.ContainsKey(schemaId)) { - var metadataSchema = await GetStableSchemaReferenceAsync( + var metadataSchema = await CreateMetadataSchemaAsync( document, context, - metadataType, - PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(schemaId), + schemaId, + contract, + openApiVersion, cancellationToken ); var schema = CreateCodeSpecificSchema( @@ -542,7 +545,7 @@ private static OpenApiSchema CreateCodeSpecificSchema( OpenApiDocument document, string baseSchemaId, string errorCode, - IOpenApiSchema metadataSchema, + IOpenApiSchema? metadataSchema, OpenApiSpecVersion openApiVersion ) { @@ -558,6 +561,15 @@ OpenApiSpecVersion openApiVersion Enum = [JsonValue.Create(errorCode)] }; + var extensionProperties = new Dictionary(StringComparer.Ordinal) + { + ["code"] = codeSchema + }; + if (metadataSchema is not null) + { + extensionProperties["metadata"] = metadataSchema; + } + return new OpenApiSchema { AllOf = @@ -566,17 +578,44 @@ OpenApiSpecVersion openApiVersion new OpenApiSchema { Type = JsonSchemaType.Object, - Properties = new Dictionary(StringComparer.Ordinal) - { - ["code"] = codeSchema, - ["metadata"] = metadataSchema - }, + Properties = extensionProperties, Required = new HashSet(StringComparer.Ordinal) { "code" } } ] }; } + private async Task CreateMetadataSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + string ownerSchemaId, + PortableErrorMetadataContract contract, + OpenApiSpecVersion openApiVersion, + CancellationToken cancellationToken + ) + { + var metadataSchemaId = PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(ownerSchemaId); + return contract switch + { + PortableErrorMetadataTypeContract typeContract => await GetStableSchemaReferenceAsync( + document, + context, + typeContract.MetadataType, + metadataSchemaId, + cancellationToken + ), + PortableErrorMetadataSchemaContract schemaContract => AddComponentAndCreateReference( + document, + metadataSchemaId, + schemaContract.SchemaFactory(openApiVersion) + ), + PortableErrorMetadataNoMetadataContract => null, + _ => throw new InvalidOperationException( + $"The error metadata contract '{contract.GetType().FullName}' is not supported." + ) + }; + } + private async Task GetStableSchemaReferenceAsync( OpenApiDocument document, OpenApiDocumentTransformerContext context, @@ -768,14 +807,14 @@ private static void ValidateInlineMetadataArrays(PortableOpenApiErrorResponseAtt } private static void AddDocumentedCode( - IDictionary rawCodeTypes, + IDictionary rawCodeContracts, string code, - Type metadataType + PortableErrorMetadataContract contract ) { - if (rawCodeTypes.TryGetValue(code, out var existingType)) + if (rawCodeContracts.TryGetValue(code, out var existingContract)) { - if (existingType == metadataType) + if (PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, contract)) { return; } @@ -783,13 +822,13 @@ Type metadataType throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( code, - existingType, - metadataType + existingContract, + contract ) ); } - rawCodeTypes.Add(code, metadataType); + rawCodeContracts.Add(code, contract); } private static string? NormalizePath(string? relativePath) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index 1f0fcf8..7b93c7e 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -1,4 +1,5 @@ using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; namespace Light.PortableResults.AspNetCore.OpenApi.Generation; @@ -11,6 +12,13 @@ Type newType ) => $"The error code '{code}' is already registered with metadata type '{existingType.FullName}'. It cannot also be registered with '{newType.FullName}'."; + internal static string CreateDuplicateErrorMetadataContractMessage( + string code, + PortableErrorMetadataContract existingContract, + PortableErrorMetadataContract newContract + ) => + $"The error code '{code}' is already registered with metadata contract '{DescribeContract(existingContract)}'. It cannot also be registered with '{DescribeContract(newContract)}'."; + internal static string CreateSanitizedErrorCodeCollisionMessage( string firstCode, string secondCode, @@ -23,4 +31,16 @@ internal static string CreateUnknownErrorCodeMessage(string code) => internal static string CreateIncompleteInlineErrorMetadataMessage() => "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataTypes together."; + + private static string DescribeContract(PortableErrorMetadataContract contract) + { + return contract switch + { + PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.FullName ?? + typeContract.MetadataType.Name, + PortableErrorMetadataSchemaContract => "schema factory", + PortableErrorMetadataNoMetadataContract => "no metadata", + _ => contract.GetType().FullName ?? contract.GetType().Name + }; + } } diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs new file mode 100644 index 0000000..af890f1 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs @@ -0,0 +1,126 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Provides typed OpenAPI metadata helpers for built-in validation error codes. +/// +public static class BuiltInValidationErrorBuilderExtensions +{ + /// Documents endpoint-specific EqualTo validation error metadata. + public static PortableProblemOpenApiBuilder WithEqualToError(this PortableProblemOpenApiBuilder builder) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.EqualTo); + + /// Documents endpoint-specific EqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.EqualTo); + + /// Documents endpoint-specific NotEqualTo validation error metadata. + public static PortableProblemOpenApiBuilder WithNotEqualToError(this PortableProblemOpenApiBuilder builder) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotEqualTo); + + /// Documents endpoint-specific NotEqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithNotEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotEqualTo); + + /// Documents endpoint-specific GreaterThan validation error metadata. + public static PortableProblemOpenApiBuilder WithGreaterThanError(this PortableProblemOpenApiBuilder builder) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.GreaterThan); + + /// Documents endpoint-specific GreaterThan validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithGreaterThanError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.GreaterThan); + + /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. + public static PortableProblemOpenApiBuilder WithGreaterThanOrEqualToError( + this PortableProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>( + ValidationErrorCodes.GreaterThanOrEqualTo + ); + + /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithGreaterThanOrEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>( + ValidationErrorCodes.GreaterThanOrEqualTo + ); + + /// Documents endpoint-specific LessThan validation error metadata. + public static PortableProblemOpenApiBuilder WithLessThanError(this PortableProblemOpenApiBuilder builder) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.LessThan); + + /// Documents endpoint-specific LessThan validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithLessThanError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.LessThan); + + /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. + public static PortableProblemOpenApiBuilder WithLessThanOrEqualToError( + this PortableProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>( + ValidationErrorCodes.LessThanOrEqualTo + ); + + /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithLessThanOrEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>( + ValidationErrorCodes.LessThanOrEqualTo + ); + + /// Documents endpoint-specific InRange validation error metadata. + public static PortableProblemOpenApiBuilder WithInRangeError(this PortableProblemOpenApiBuilder builder) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.InRange); + + /// Documents endpoint-specific InRange validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithInRangeError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.InRange); + + /// Documents endpoint-specific NotInRange validation error metadata. + public static PortableProblemOpenApiBuilder WithNotInRangeError(this PortableProblemOpenApiBuilder builder) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotInRange); + + /// Documents endpoint-specific NotInRange validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithNotInRangeError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotInRange); + + /// Documents endpoint-specific ExclusiveRange validation error metadata. + public static PortableProblemOpenApiBuilder WithExclusiveRangeError( + this PortableProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.ExclusiveRange); + + /// Documents endpoint-specific ExclusiveRange validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithExclusiveRangeError( + this PortableValidationProblemOpenApiBuilder builder + ) => + EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.ExclusiveRange); + + private static PortableProblemOpenApiBuilder EnsureBuilder(PortableProblemOpenApiBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder; + } + + private static PortableValidationProblemOpenApiBuilder EnsureBuilder(PortableValidationProblemOpenApiBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder; + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs new file mode 100644 index 0000000..d69d90b --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs @@ -0,0 +1,44 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Provides registration helpers for built-in validation error metadata contracts. +/// +public static class BuiltInValidationErrorContractRegistrationExtensions +{ + /// + /// Registers the built-in validation error metadata contracts. + /// + /// The error metadata contract builder. + /// The configured builder. + public static PortableErrorMetadataContractsBuilder RegisterBuiltInValidationErrors( + this PortableErrorMetadataContractsBuilder builder + ) + { + ArgumentNullException.ThrowIfNull(builder); + + foreach (var (code, contract) in BuiltInValidationErrorContracts.Contracts) + { + switch (contract) + { + case PortableErrorMetadataTypeContract typeContract: + builder.ForCode(code, typeContract.MetadataType); + break; + case PortableErrorMetadataSchemaContract schemaContract: + builder.ForCode(code, schemaContract.SchemaFactory); + break; + case PortableErrorMetadataNoMetadataContract: + builder.ForCode(code); + break; + default: + throw new InvalidOperationException( + $"The error metadata contract '{contract.GetType().FullName}' is not supported." + ); + } + } + + return builder; + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs new file mode 100644 index 0000000..5dc610e --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.Validation.Definitions; +using Microsoft.OpenApi; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Provides OpenAPI metadata contracts for built-in validation error codes. +/// +public static class BuiltInValidationErrorContracts +{ + /// + /// Gets the built-in validation error metadata contracts. + /// + public static IReadOnlyDictionary Contracts { get; } = CreateContracts(); + + private static IReadOnlyDictionary CreateContracts() + { + return new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorCodes.Count] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.ExpectedCount)), + [ValidationErrorCodes.MinCount] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MinCount)), + [ValidationErrorCodes.MaxCount] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MaxCount)), + [ValidationErrorCodes.MinLength] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MinLength)), + [ValidationErrorCodes.MaxLength] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MaxLength)), + [ValidationErrorCodes.LengthInRange] = Schema( + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.MinLength] = IntegerSchema(), + [ValidationErrorMetadataKeys.MaxLength] = IntegerSchema() + } + ) + ), + [ValidationErrorCodes.EqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.NotEqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.GreaterThan] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.GreaterThanOrEqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.LessThan] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.LessThanOrEqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.InRange] = Schema(ObjectWithPrimitiveRange()), + [ValidationErrorCodes.NotInRange] = Schema(ObjectWithPrimitiveRange()), + [ValidationErrorCodes.ExclusiveRange] = Schema(ObjectWithPrimitiveRange()), + [ValidationErrorCodes.Pattern] = Schema( + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.Pattern] = StringSchema(), + [ValidationErrorMetadataKeys.RegexOptions] = IntegerSchema() + } + ) + ), + [ValidationErrorCodes.Enum] = Schema(ObjectWithString(ValidationErrorMetadataKeys.EnumType)), + [ValidationErrorCodes.EnumName] = Schema( + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.EnumType] = StringSchema(), + [ValidationErrorMetadataKeys.IgnoreCase] = BooleanSchema() + } + ) + ), + [ValidationErrorCodes.PrecisionScale] = Schema( + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.ExpectedPrecision] = IntegerSchema(), + [ValidationErrorMetadataKeys.ExpectedScale] = IntegerSchema(), + [ValidationErrorMetadataKeys.IgnoreTrailingZeros] = BooleanSchema() + } + ) + ), + [ValidationErrorCodes.NotNull] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Null] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.NotEmpty] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Empty] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.NotNullOrWhiteSpace] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Email] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.DigitsOnly] = PortableErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.LettersAndDigitsOnly] = PortableErrorMetadataContract.NoMetadata + }; + } + + private static PortableErrorMetadataContract Schema(Func schemaFactory) => + PortableErrorMetadataContract.FromSchema(schemaFactory); + + private static Func ObjectWithInteger(string propertyName) => + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [propertyName] = IntegerSchema() + } + ); + + private static Func ObjectWithString(string propertyName) => + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [propertyName] = StringSchema() + } + ); + + private static Func ObjectWithPrimitiveValue(string propertyName) => + version => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [propertyName] = PrimitiveValueSchema(version) + } + ); + + private static Func ObjectWithPrimitiveRange() => + version => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.LowerBoundary] = PrimitiveValueSchema(version), + [ValidationErrorMetadataKeys.UpperBoundary] = PrimitiveValueSchema(version) + } + ); + + private static OpenApiSchema CreateObjectSchema(Dictionary properties) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = properties, + Required = new HashSet(properties.Keys, StringComparer.Ordinal) + }; + } + + private static OpenApiSchema PrimitiveValueSchema(OpenApiSpecVersion version) + { + var oneOf = new List + { + StringSchema(), + NumberSchema(), + IntegerSchema(), + BooleanSchema() + }; + + if (version >= OpenApiSpecVersion.OpenApi3_1) + { + oneOf.Add(NullSchema()); + return new OpenApiSchema { OneOf = oneOf }; + } + + return new OpenApiSchema + { + Type = JsonSchemaType.Null, + OneOf = oneOf + }; + } + + private static OpenApiSchema StringSchema() => new () { Type = JsonSchemaType.String }; + + private static OpenApiSchema NumberSchema() => new () { Type = JsonSchemaType.Number }; + + private static OpenApiSchema IntegerSchema() => new () { Type = JsonSchemaType.Integer }; + + private static OpenApiSchema BooleanSchema() => new () { Type = JsonSchemaType.Boolean }; + + private static OpenApiSchema NullSchema() => new () { Type = JsonSchemaType.Null }; +} diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs new file mode 100644 index 0000000..1abc258 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; + +namespace Light.PortableResults.Validation.OpenApi; + +/// Metadata contract for endpoint-specific EqualTo validation errors. +/// The expected value. +public sealed record EqualToMetadata([property: Required] T ComparativeValue); + +/// Metadata contract for endpoint-specific NotEqualTo validation errors. +/// The disallowed value. +public sealed record NotEqualToMetadata([property: Required] T ComparativeValue); + +/// Metadata contract for endpoint-specific GreaterThan validation errors. +/// The lower exclusive boundary. +public sealed record GreaterThanMetadata([property: Required] T ComparativeValue); + +/// Metadata contract for endpoint-specific GreaterThanOrEqualTo validation errors. +/// The lower inclusive boundary. +public sealed record GreaterThanOrEqualToMetadata([property: Required] T ComparativeValue); + +/// Metadata contract for endpoint-specific LessThan validation errors. +/// The upper exclusive boundary. +public sealed record LessThanMetadata([property: Required] T ComparativeValue); + +/// Metadata contract for endpoint-specific LessThanOrEqualTo validation errors. +/// The upper inclusive boundary. +public sealed record LessThanOrEqualToMetadata([property: Required] T ComparativeValue); + +/// Metadata contract for endpoint-specific InRange validation errors. +/// The lower boundary. +/// The upper boundary. +public sealed record InRangeMetadata( + [property: Required] T LowerBoundary, + [property: Required] T UpperBoundary +); + +/// Metadata contract for endpoint-specific NotInRange validation errors. +/// The lower boundary. +/// The upper boundary. +public sealed record NotInRangeMetadata( + [property: Required] T LowerBoundary, + [property: Required] T UpperBoundary +); + +/// Metadata contract for endpoint-specific ExclusiveRange validation errors. +/// The lower exclusive boundary. +/// The upper exclusive boundary. +public sealed record ExclusiveRangeMetadata( + [property: Required] T LowerBoundary, + [property: Required] T UpperBoundary +); diff --git a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj new file mode 100644 index 0000000..60ee6c3 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj @@ -0,0 +1,22 @@ + + + + true + OpenAPI bridge package for Light.PortableResults.Validation. Provides built-in validation error metadata contracts and typed helpers for endpoint-specific validation error narrowing. + + Light.PortableResults.Validation.OpenApi 0.4.0 + ---------------------------------------- + + - Built-in OpenAPI metadata contracts for Light.PortableResults.Validation error codes. + - Opt-in registration extension for the global PortableResults OpenAPI contract registry. + - Typed comparison and range helper metadata contracts for endpoint-specific narrowing. + - Native AOT compatible. + + + + + + + + + diff --git a/src/Light.PortableResults.Validation.OpenApi/packages.lock.json b/src/Light.PortableResults.Validation.OpenApi/packages.lock.json new file mode 100644 index 0000000..b853b1b --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/packages.lock.json @@ -0,0 +1,117 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.201, )", + "resolved": "10.0.201", + "contentHash": "qxYAmO4ktzd9L+HMdnqWucxpu7bI9undPyACXOMqPyhaiMtbpbYL/n0ACyWIJlbyEJrXFwxiOaBOSasLtDvsCg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.201", + "Microsoft.SourceLink.Common": "10.0.201", + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==" + }, + "light.portableresults": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.HashCode": "[6.0.0, )", + "Microsoft.Extensions.Options": "[10.0.5, )", + "Microsoft.Extensions.Primitives": "[10.0.5, )", + "Ulid": "[1.4.1, )" + } + }, + "light.portableresults.aspnetcore.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "light.portableresults.aspnetcore.shared": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "light.portableresults.validation": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "CentralTransitive", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" + }, + "Ulid": { + "type": "CentralTransitive", + "requested": "[1.4.1, )", + "resolved": "1.4.1", + "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" + } + } + } +} \ No newline at end of file diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs index 86497c9..334e9f7 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs @@ -247,7 +247,7 @@ public sealed class GreaterThanValidationErrorDefinition : ValidationErrorDef public GreaterThanValidationErrorDefinition(T comparativeValue) : base( comparativeValue, - code: "GreaterThan", + code: ValidationErrorCodes.GreaterThan, metadata: CreateComparativeMetadata(comparativeValue) ) { @@ -283,7 +283,7 @@ public sealed class GreaterThanOrEqualToValidationErrorDefinition : Validatio public GreaterThanOrEqualToValidationErrorDefinition(T comparativeValue) : base( comparativeValue, - code: "GreaterThanOrEqualTo", + code: ValidationErrorCodes.GreaterThanOrEqualTo, metadata: CreateComparativeMetadata(comparativeValue) ) { @@ -322,7 +322,7 @@ public sealed class LessThanValidationErrorDefinition : ValidationErrorDefini public LessThanValidationErrorDefinition(T comparativeValue) : base( comparativeValue, - code: "LessThan", + code: ValidationErrorCodes.LessThan, metadata: CreateComparativeMetadata(comparativeValue) ) { @@ -358,7 +358,7 @@ public sealed class LessThanOrEqualToValidationErrorDefinition : ValidationEr public LessThanOrEqualToValidationErrorDefinition(T comparativeValue) : base( comparativeValue, - code: "LessThanOrEqualTo", + code: ValidationErrorCodes.LessThanOrEqualTo, metadata: CreateComparativeMetadata(comparativeValue) ) { @@ -397,7 +397,7 @@ public sealed class InBetweenValidationErrorDefinition : ValidationErrorDefin public InBetweenValidationErrorDefinition(T lowerBoundary, T upperBoundary) : base( new ValidationRange(lowerBoundary, upperBoundary), - code: "IsInBetween", + code: ValidationErrorCodes.InRange, metadata: CreateRangeMetadata(lowerBoundary, upperBoundary) ) { @@ -439,7 +439,7 @@ public sealed class NotInBetweenValidationErrorDefinition : ValidationErrorDe public NotInBetweenValidationErrorDefinition(T lowerBoundary, T upperBoundary) : base( new ValidationRange(lowerBoundary, upperBoundary), - code: "NotInBetween", + code: ValidationErrorCodes.NotInRange, metadata: CreateRangeMetadata(lowerBoundary, upperBoundary) ) { @@ -481,7 +481,7 @@ public sealed class ExclusiveRangeValidationErrorDefinition : ValidationError public ExclusiveRangeValidationErrorDefinition(T lowerBoundary, T upperBoundary) : base( new ValidationRange(lowerBoundary, upperBoundary), - code: "ExclusiveRange", + code: ValidationErrorCodes.ExclusiveRange, metadata: CreateRangeMetadata(lowerBoundary, upperBoundary) ) { diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Count.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Count.cs index 5e86be4..b6104c1 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Count.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Count.cs @@ -91,7 +91,7 @@ public sealed class CountValidationErrorDefinition : ValidationErrorDefinition. /// public EmptyValidationErrorDefinition() - : base(code: "Empty") { } + : base(code: ValidationErrorCodes.Empty) { } /// public override bool TryGetStableMessageProvider( @@ -45,7 +45,7 @@ public sealed class NotEmptyValidationErrorDefinition : ValidationErrorDefinitio /// Initializes a new instance of . /// public NotEmptyValidationErrorDefinition() - : base(code: "NotEmpty") { } + : base(code: ValidationErrorCodes.NotEmpty) { } /// public override bool TryGetStableMessageProvider( diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Enums.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Enums.cs index be0b462..eb879b0 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Enums.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Enums.cs @@ -50,7 +50,7 @@ public sealed class EnumValidationErrorDefinition : ValidationErrorDefini /// public EnumValidationErrorDefinition() : base( - code: "Enum", + code: ValidationErrorCodes.Enum, metadata: CreateEnumMetadata(typeof(TEnum)) ) { } @@ -77,7 +77,7 @@ public sealed class EnumNameValidationErrorDefinition : ValidationErrorDe public EnumNameValidationErrorDefinition(bool ignoreCase) : base( ignoreCase, - code: "EnumName", + code: ValidationErrorCodes.EnumName, metadata: CreateEnumNameMetadata(typeof(TEnum), ignoreCase) ) { diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Equality.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Equality.cs index 89cb14e..741e687 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Equality.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Equality.cs @@ -66,7 +66,7 @@ public sealed class EqualToValidationErrorDefinition : ValidationErrorDefinit public EqualToValidationErrorDefinition(T comparativeValue) : base( comparativeValue, - code: "EqualTo", + code: ValidationErrorCodes.EqualTo, metadata: CreateComparativeMetadata(comparativeValue) ) { @@ -102,7 +102,7 @@ public sealed class NotEqualToValidationErrorDefinition : ValidationErrorDefi public NotEqualToValidationErrorDefinition(T comparativeValue) : base( comparativeValue, - code: "NotEqualTo", + code: ValidationErrorCodes.NotEqualTo, metadata: CreateComparativeMetadata(comparativeValue) ) { diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Null.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Null.cs index 02f4fd5..9e11911 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Null.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Null.cs @@ -23,7 +23,7 @@ public sealed class NotNullValidationErrorDefinition : ValidationErrorDefinition /// Initializes a new instance of . /// public NotNullValidationErrorDefinition() - : base(code: "NotNull") { } + : base(code: ValidationErrorCodes.NotNull) { } /// public override bool TryGetStableMessageProvider( @@ -45,7 +45,7 @@ public sealed class NullValidationErrorDefinition : ValidationErrorDefinition /// Initializes a new instance of . /// public NullValidationErrorDefinition() - : base(code: "Null") { } + : base(code: ValidationErrorCodes.Null) { } /// public override bool TryGetStableMessageProvider( diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Predicate.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Predicate.cs index 9b57e45..5c041b5 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Predicate.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Predicate.cs @@ -18,7 +18,7 @@ public sealed class PredicateValidationErrorDefinition : ValidationErrorDefiniti /// Initializes a new instance of . /// public PredicateValidationErrorDefinition() - : base(code: "Predicate") { } + : base(code: ValidationErrorCodes.Predicate) { } /// public override bool TryGetStableMessageProvider( diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Strings.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Strings.cs index e95141d..3f04d14 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Strings.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Strings.cs @@ -144,7 +144,7 @@ public sealed class NotNullOrWhiteSpaceValidationErrorDefinition : ValidationErr /// Initializes a new instance of . /// public NotNullOrWhiteSpaceValidationErrorDefinition() - : base(code: "NotNullOrWhiteSpace") { } + : base(code: ValidationErrorCodes.NotNullOrWhiteSpace) { } /// public override bool TryGetStableMessageProvider( @@ -168,7 +168,7 @@ public sealed class MinLengthValidationErrorDefinition : ValidationErrorDefiniti public MinLengthValidationErrorDefinition(int minLength) : base( minLength, - code: "MinLength", + code: ValidationErrorCodes.MinLength, metadata: CreateCountMetadata(ValidationErrorMetadataKeys.MinLength, minLength) ) { @@ -203,7 +203,7 @@ public sealed class MaxLengthValidationErrorDefinition : ValidationErrorDefiniti public MaxLengthValidationErrorDefinition(int maxLength) : base( maxLength, - code: "MaxLength", + code: ValidationErrorCodes.MaxLength, metadata: CreateCountMetadata(ValidationErrorMetadataKeys.MaxLength, maxLength) ) { @@ -238,7 +238,7 @@ public sealed class LengthInValidationErrorDefinition : ValidationErrorDefinitio public LengthInValidationErrorDefinition(int minLength, int maxLength) : base( new ValidationRange(minLength, maxLength), - code: "LengthIn", + code: ValidationErrorCodes.LengthInRange, metadata: CreateLengthMetadata(minLength, maxLength) ) { @@ -278,7 +278,7 @@ public sealed class PatternValidationErrorDefinition : ValidationErrorDefinition /// public PatternValidationErrorDefinition(string pattern, RegexOptions options) : base( - code: "Matches", + code: ValidationErrorCodes.Pattern, metadata: CreateRegexMetadata(pattern, options) ) { @@ -322,7 +322,7 @@ public sealed class EmailValidationErrorDefinition : ValidationErrorDefinition /// Initializes a new instance of . /// public EmailValidationErrorDefinition() - : base(code: "Email") { } + : base(code: ValidationErrorCodes.Email) { } /// public override bool TryGetStableMessageProvider( @@ -344,7 +344,7 @@ public sealed class DigitsOnlyValidationErrorDefinition : ValidationErrorDefinit /// Initializes a new instance of . /// public DigitsOnlyValidationErrorDefinition() - : base(code: "DigitsOnly") { } + : base(code: ValidationErrorCodes.DigitsOnly) { } /// public override bool TryGetStableMessageProvider( @@ -366,7 +366,7 @@ public sealed class LettersAndDigitsOnlyValidationErrorDefinition : ValidationEr /// Initializes a new instance of . /// public LettersAndDigitsOnlyValidationErrorDefinition() - : base(code: "LettersAndDigitsOnly") { } + : base(code: ValidationErrorCodes.LettersAndDigitsOnly) { } /// public override bool TryGetStableMessageProvider( diff --git a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs new file mode 100644 index 0000000..bf32445 --- /dev/null +++ b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs @@ -0,0 +1,64 @@ +namespace Light.PortableResults.Validation; + +/// +/// Defines the built-in validation error codes emitted by Light.PortableResults.Validation. +/// +public static class ValidationErrorCodes +{ + /// Validation error code for exact-count failures. + public const string Count = "Count"; + /// Validation error code for minimum-count failures. + public const string MinCount = "MinCount"; + /// Validation error code for maximum-count failures. + public const string MaxCount = "MaxCount"; + /// Validation error code for minimum-length failures. + public const string MinLength = "MinLength"; + /// Validation error code for maximum-length failures. + public const string MaxLength = "MaxLength"; + /// Validation error code for length-range failures. + public const string LengthInRange = "LengthInRange"; + /// Validation error code for equality failures. + public const string EqualTo = "EqualTo"; + /// Validation error code for inequality failures. + public const string NotEqualTo = "NotEqualTo"; + /// Validation error code for greater-than failures. + public const string GreaterThan = "GreaterThan"; + /// Validation error code for greater-than-or-equal failures. + public const string GreaterThanOrEqualTo = "GreaterThanOrEqualTo"; + /// Validation error code for less-than failures. + public const string LessThan = "LessThan"; + /// Validation error code for less-than-or-equal failures. + public const string LessThanOrEqualTo = "LessThanOrEqualTo"; + /// Validation error code for inclusive range failures. + public const string InRange = "InRange"; + /// Validation error code for outside-range failures. + public const string NotInRange = "NotInRange"; + /// Validation error code for exclusive-range failures. + public const string ExclusiveRange = "ExclusiveRange"; + /// Validation error code for regular-expression pattern failures. + public const string Pattern = "Pattern"; + /// Validation error code for enum-value failures. + public const string Enum = "Enum"; + /// Validation error code for enum-name failures. + public const string EnumName = "EnumName"; + /// Validation error code for decimal precision-and-scale failures. + public const string PrecisionScale = "PrecisionScale"; + /// Validation error code for null-value failures. + public const string NotNull = "NotNull"; + /// Validation error code for must-be-null failures. + public const string Null = "Null"; + /// Validation error code for not-empty failures. + public const string NotEmpty = "NotEmpty"; + /// Validation error code for empty-value failures. + public const string Empty = "Empty"; + /// Validation error code for null-or-empty-or-whitespace string failures. + public const string NotNullOrWhiteSpace = "NotNullOrWhiteSpace"; + /// Validation error code for email-format failures. + public const string Email = "Email"; + /// Validation error code for digits-only failures. + public const string DigitsOnly = "DigitsOnly"; + /// Validation error code for letters-and-digits-only failures. + public const string LettersAndDigitsOnly = "LettersAndDigitsOnly"; + /// Validation error code for predicate-based failures. + public const string Predicate = "Predicate"; +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs new file mode 100644 index 0000000..c7dec59 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.Definitions; +using Light.PortableResults.Validation.OpenApi; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class BuiltInValidationErrorContractsTests +{ + private static readonly string[] MetadataBearingCodes = + [ + ValidationErrorCodes.Count, + ValidationErrorCodes.MinCount, + ValidationErrorCodes.MaxCount, + ValidationErrorCodes.MinLength, + ValidationErrorCodes.MaxLength, + ValidationErrorCodes.LengthInRange, + ValidationErrorCodes.EqualTo, + ValidationErrorCodes.NotEqualTo, + ValidationErrorCodes.GreaterThan, + ValidationErrorCodes.GreaterThanOrEqualTo, + ValidationErrorCodes.LessThan, + ValidationErrorCodes.LessThanOrEqualTo, + ValidationErrorCodes.InRange, + ValidationErrorCodes.NotInRange, + ValidationErrorCodes.ExclusiveRange, + ValidationErrorCodes.Pattern, + ValidationErrorCodes.Enum, + ValidationErrorCodes.EnumName, + ValidationErrorCodes.PrecisionScale + ]; + + public static TheoryData MetadataCodeProperties => + new () + { + { ValidationErrorCodes.Count, [ValidationErrorMetadataKeys.ExpectedCount] }, + { ValidationErrorCodes.MinCount, [ValidationErrorMetadataKeys.MinCount] }, + { ValidationErrorCodes.MaxCount, [ValidationErrorMetadataKeys.MaxCount] }, + { ValidationErrorCodes.MinLength, [ValidationErrorMetadataKeys.MinLength] }, + { ValidationErrorCodes.MaxLength, [ValidationErrorMetadataKeys.MaxLength] }, + { + ValidationErrorCodes.LengthInRange, + [ValidationErrorMetadataKeys.MinLength, ValidationErrorMetadataKeys.MaxLength] + }, + { ValidationErrorCodes.EqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { ValidationErrorCodes.NotEqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { ValidationErrorCodes.GreaterThan, [ValidationErrorMetadataKeys.ComparativeValue] }, + { ValidationErrorCodes.GreaterThanOrEqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { ValidationErrorCodes.LessThan, [ValidationErrorMetadataKeys.ComparativeValue] }, + { ValidationErrorCodes.LessThanOrEqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { + ValidationErrorCodes.InRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + ValidationErrorCodes.NotInRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + ValidationErrorCodes.ExclusiveRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { ValidationErrorCodes.Pattern, [ValidationErrorMetadataKeys.Pattern, ValidationErrorMetadataKeys.RegexOptions] }, + { ValidationErrorCodes.Enum, [ValidationErrorMetadataKeys.EnumType] }, + { ValidationErrorCodes.EnumName, [ValidationErrorMetadataKeys.EnumType, ValidationErrorMetadataKeys.IgnoreCase] }, + { + ValidationErrorCodes.PrecisionScale, + [ + ValidationErrorMetadataKeys.ExpectedPrecision, + ValidationErrorMetadataKeys.ExpectedScale, + ValidationErrorMetadataKeys.IgnoreTrailingZeros + ] + } + }; + + public static TheoryData PrimitiveValueCodes => + new () + { + ValidationErrorCodes.EqualTo, + ValidationErrorCodes.NotEqualTo, + ValidationErrorCodes.GreaterThan, + ValidationErrorCodes.GreaterThanOrEqualTo, + ValidationErrorCodes.LessThan, + ValidationErrorCodes.LessThanOrEqualTo, + ValidationErrorCodes.InRange, + ValidationErrorCodes.NotInRange, + ValidationErrorCodes.ExclusiveRange + }; + + [Fact] + public void Contracts_ShouldContainExpectedBuiltInCodes() + { + string[] expectedNoMetadataCodes = + [ + ValidationErrorCodes.NotNull, + ValidationErrorCodes.Null, + ValidationErrorCodes.NotEmpty, + ValidationErrorCodes.Empty, + ValidationErrorCodes.NotNullOrWhiteSpace, + ValidationErrorCodes.Email, + ValidationErrorCodes.DigitsOnly, + ValidationErrorCodes.LettersAndDigitsOnly + ]; + + BuiltInValidationErrorContracts.Contracts.Keys.Should() + .BeEquivalentTo(MetadataBearingCodes.Concat(expectedNoMetadataCodes)); + BuiltInValidationErrorContracts.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); + expectedNoMetadataCodes.Should().OnlyContain( + code => ReferenceEquals( + BuiltInValidationErrorContracts.Contracts[code], + PortableErrorMetadataContract.NoMetadata + ) + ); + } + + [Theory] + [MemberData(nameof(MetadataCodeProperties))] + public void MetadataContracts_ShouldEmitExpectedObjectProperties(string code, string[] expectedProperties) + { + var contract = BuiltInValidationErrorContracts.Contracts[code] + .Should() + .BeOfType() + .Subject; + + var firstSchema = contract.SchemaFactory(OpenApiSpecVersion.OpenApi3_1); + var secondSchema = contract.SchemaFactory(OpenApiSpecVersion.OpenApi3_1); + + firstSchema.Should().NotBeSameAs(secondSchema); + firstSchema.Type.Should().Be(JsonSchemaType.Object); + firstSchema.Properties!.Keys.Should().BeEquivalentTo(expectedProperties); + firstSchema.Required.Should().BeEquivalentTo(expectedProperties); + } + + [Theory] + [MemberData(nameof(PrimitiveValueCodes))] + public void PrimitiveValueSchemas_ShouldUseOpenApi31NullBranch(string code) + { + var primitiveSchema = GetFirstPrimitiveValueSchema(code, OpenApiSpecVersion.OpenApi3_1); + + primitiveSchema.Type.Should().BeNull(); + primitiveSchema.OneOf.Should().NotBeNull(); + primitiveSchema.OneOf!.Select(static schema => ((OpenApiSchema) schema).Type).Should() + .BeEquivalentTo( + new JsonSchemaType?[] + { + JsonSchemaType.String, + JsonSchemaType.Number, + JsonSchemaType.Integer, + JsonSchemaType.Boolean, + JsonSchemaType.Null + } + ); + } + + [Theory] + [MemberData(nameof(PrimitiveValueCodes))] + public void PrimitiveValueSchemas_ShouldUseOpenApi30NullableParentWithoutNullBranch(string code) + { + var primitiveSchema = GetFirstPrimitiveValueSchema(code, OpenApiSpecVersion.OpenApi3_0); + + primitiveSchema.Type.Should().Be(JsonSchemaType.Null); + primitiveSchema.OneOf.Should().NotBeNull(); + primitiveSchema.OneOf!.Select(static schema => ((OpenApiSchema) schema).Type).Should() + .BeEquivalentTo( + new JsonSchemaType?[] + { + JsonSchemaType.String, + JsonSchemaType.Number, + JsonSchemaType.Integer, + JsonSchemaType.Boolean + } + ); + } + + [Fact] + public void RegisterBuiltInValidationErrors_ShouldRegisterExpectedCodes() + { + var builder = new PortableErrorMetadataContractsBuilder(); + + builder.RegisterBuiltInValidationErrors(); + var registry = new PortableErrorMetadataContractRegistry(builder); + + registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); + registry.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); + } + + private static OpenApiSchema GetFirstPrimitiveValueSchema(string code, OpenApiSpecVersion version) + { + var contract = (PortableErrorMetadataSchemaContract) BuiltInValidationErrorContracts.Contracts[code]; + var schema = contract.SchemaFactory(version); + var propertyName = schema.Properties!.ContainsKey(ValidationErrorMetadataKeys.ComparativeValue) ? + ValidationErrorMetadataKeys.ComparativeValue : + ValidationErrorMetadataKeys.LowerBoundary; + return (OpenApiSchema) schema.Properties[propertyName]; + } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj index dc62581..23431e2 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs new file mode 100644 index 0000000..b9078f8 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class PortableErrorMetadataContractTests +{ + [Fact] + public void ContractFactories_ShouldExposeClosedSubclassPayloads() + { + Func schemaFactory = _ => new OpenApiSchema(); + + var typeContract = PortableErrorMetadataContract.FromType(typeof(TestMetadata)); + var schemaContract = PortableErrorMetadataContract.FromSchema(schemaFactory); + var noMetadata = PortableErrorMetadataContract.NoMetadata; + + typeContract.Should().BeOfType() + .Which.MetadataType.Should().Be(typeof(TestMetadata)); + schemaContract.Should().BeOfType() + .Which.SchemaFactory.Should().BeSameAs(schemaFactory); + noMetadata.Should().BeOfType(); + PortableErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); + typeof(PortableErrorMetadataContract) + .GetMember("Kind") + .Should() + .BeEmpty(); + + string discriminator = typeContract switch + { + PortableErrorMetadataTypeContract => "type", + PortableErrorMetadataSchemaContract => "schema", + PortableErrorMetadataNoMetadataContract => "none", + _ => "unknown" + }; + discriminator.Should().Be("type"); + } + + [Fact] + public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() + { + Func schemaFactory = _ => new OpenApiSchema(); + var builder = new PortableErrorMetadataContractsBuilder(); + + builder.ForCode("TypeCode"); + builder.ForCode("TypeCode", typeof(TestMetadata)); + builder.ForCode("SchemaCode", schemaFactory); + builder.ForCode("SchemaCode", schemaFactory); + builder.ForCode("NoMetadataCode"); + builder.ForCode("NoMetadataCode"); + + var registry = new PortableErrorMetadataContractRegistry(builder); + registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode", "SchemaCode", "NoMetadataCode"); + } + + [Fact] + public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() + { + Func firstFactory = _ => new OpenApiSchema(); + Func secondFactory = _ => new OpenApiSchema(); + + new Action( + () => new PortableErrorMetadataContractsBuilder() + .ForCode("Conflict") + .ForCode("Conflict") + ) + .Should() + .Throw() + .WithMessage("*Conflict*TestMetadata*OtherMetadata*"); + + new Action( + () => new PortableErrorMetadataContractsBuilder() + .ForCode("Conflict", firstFactory) + .ForCode("Conflict", secondFactory) + ) + .Should() + .Throw() + .WithMessage("*Conflict*schema factory*"); + + new Action( + () => new PortableErrorMetadataContractsBuilder() + .ForCode("Conflict") + .ForCode("Conflict") + ) + .Should() + .Throw() + .WithMessage("*Conflict*no metadata*TestMetadata*"); + } + + [Fact] + public void ContractRegistry_ShouldExposePortableMetadataContracts() + { + var registry = new PortableErrorMetadataContractRegistry( + new PortableErrorMetadataContractsBuilder().ForCode("TypeCode") + ); + + registry.Contracts.Should().BeAssignableTo>(); + registry.Contracts["TypeCode"].Should().BeOfType(); + } + + private sealed class TestMetadata + { + public string Value { get; init; } = string.Empty; + } + + private sealed class OtherMetadata + { + public string Value { get; init; } = string.Empty; + } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs new file mode 100644 index 0000000..190765d --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.Definitions; +using Light.PortableResults.Validation.OpenApi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class ValidationOpenApiDocumentTransformerTests +{ + public static TheoryData TypedHelperCases => + new () + { + { "EqualTo", ValidationErrorCodes.EqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { "NotEqualTo", ValidationErrorCodes.NotEqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { "GreaterThan", ValidationErrorCodes.GreaterThan, [ValidationErrorMetadataKeys.ComparativeValue] }, + { + "GreaterThanOrEqualTo", + ValidationErrorCodes.GreaterThanOrEqualTo, + [ValidationErrorMetadataKeys.ComparativeValue] + }, + { "LessThan", ValidationErrorCodes.LessThan, [ValidationErrorMetadataKeys.ComparativeValue] }, + { + "LessThanOrEqualTo", + ValidationErrorCodes.LessThanOrEqualTo, + [ValidationErrorMetadataKeys.ComparativeValue] + }, + { + "InRange", + ValidationErrorCodes.InRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + "NotInRange", + ValidationErrorCodes.NotInRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + "ExclusiveRange", + ValidationErrorCodes.ExclusiveRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + } + }; + + [Fact] + public async Task Transformer_ShouldEmitCodeOnlyExtensionForNoMetadataCodes() + { + await using var app = CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/no-metadata", static () => Results.Problem()) + .WithName("NoMetadata") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes(ValidationErrorCodes.NotNull) + ); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var component = GetSchemaComponent(document, "PortableError__NotNull"); + var extension = (OpenApiSchema) component.AllOf![1]; + + extension.Properties!.Keys.Should().BeEquivalentTo("code"); + extension.Properties.Should().NotContainKey("metadata"); + extension.Required.Should().BeEquivalentTo("code"); + } + + [Fact] + public async Task Transformer_ShouldEmitMetadataAndNoMetadataBuiltInCodesTogether() + { + await using var app = CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/count-and-not-null", static () => Results.Problem()) + .WithName("CountAndNotNull") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes(ValidationErrorCodes.Count, ValidationErrorCodes.NotNull) + ); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var responseItems = GetProblemItems(document, "/count-and-not-null"); + + responseItems.AnyOf!.Select(static schema => GetSchemaReferenceId((OpenApiSchemaReference) schema)) + .Should() + .Contain(["PortableError__Count", "PortableError__NotNull", "PortableError"]); + var countMetadata = GetSchemaComponent(document, "PortableError__Count__Metadata"); + countMetadata.Properties!.Keys.Should().BeEquivalentTo(ValidationErrorMetadataKeys.ExpectedCount); + ((OpenApiSchema) countMetadata.Properties[ValidationErrorMetadataKeys.ExpectedCount]).Type.Should() + .Be(JsonSchemaType.Integer); + } + + [Fact] + public async Task Transformer_ShouldProduceValidOpenApi30DocumentWithBuiltInCatalog() + { + await using var app = CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/catalog", static () => Results.Problem()) + .WithName("Catalog") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes(BuiltInValidationErrorContracts.Contracts.Keys.ToArray()) + ); + }, + options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 + ); + + var document = await GetOpenApiDocumentAsync(app); + var validationErrors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + await using var stream = new MemoryStream(); + await document.SerializeAsJsonAsync(stream, OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + validationErrors.Should().BeEmpty(); + json.Should().NotContain("\"type\":\"null\""); + } + + [Fact] + public async Task Transformer_ShouldKeepTypeAndSchemaContractEnvelopeShapeEquivalent() + { + await using var typeApp = CreateApp( + contracts => contracts.ForCode("Equivalent"), + endpoints => + { + endpoints + .MapGet("/equivalent", static () => Results.Problem()) + .WithName("EquivalentType") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes("Equivalent") + ); + } + ); + await using var schemaApp = CreateApp( + contracts => contracts.ForCode( + "Equivalent", + _ => new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["value"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + }, + Required = new HashSet(StringComparer.Ordinal) { "value" } + } + ), + endpoints => + { + endpoints + .MapGet("/equivalent", static () => Results.Problem()) + .WithName("EquivalentSchema") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes("Equivalent") + ); + } + ); + + var typeExtension = GetCodeSpecificExtension(await GetOpenApiDocumentAsync(typeApp), "PortableError__Equivalent"); + var schemaExtension = + GetCodeSpecificExtension(await GetOpenApiDocumentAsync(schemaApp), "PortableError__Equivalent"); + + typeExtension.Properties!.Keys.Should().BeEquivalentTo(schemaExtension.Properties!.Keys); + typeExtension.Required.Should().BeEquivalentTo(schemaExtension.Required); + typeExtension.Properties["metadata"].Should().BeOfType(); + schemaExtension.Properties["metadata"].Should().BeOfType(); + } + + [Theory] + [MemberData(nameof(TypedHelperCases))] + public async Task TypedHelpers_ShouldEmitEndpointScopedIntegerMetadata( + string operationName, + string code, + string[] properties + ) + { + await using var app = CreateTypedHelperApp(operationName); + + var document = await GetOpenApiDocumentAsync(app); + var metadata = GetSchemaComponent( + document, + $"PortableError__{operationName}__400__application_problem_json__{code}__Metadata" + ); + + metadata.Properties!.Keys.Should().BeEquivalentTo(properties); + metadata.Required.Should().BeEquivalentTo(properties); + properties.Should().OnlyContain( + property => SchemaIncludesType((OpenApiSchema) metadata.Properties[property], JsonSchemaType.Integer) + ); + } + + [Fact] + public async Task TypedHelpers_ShouldEmitEndpointScopedDateTimeMetadata() + { + await using var app = CreateTypedHelperApp("InRangeDateTime"); + + var document = await GetOpenApiDocumentAsync(app); + var metadata = GetSchemaComponent( + document, + "PortableError__InRangeDateTime__400__application_problem_json__InRange__Metadata" + ); + + foreach (var property in new[] { ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary }) + { + var schema = (OpenApiSchema) metadata.Properties![property]; + SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); + schema.Format.Should().Be("date-time"); + } + } + + [Fact] + public async Task Transformer_ShouldMixGlobalAndEndpointScopedBuiltInContracts() + { + await using var app = CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/mixed", static () => Results.Problem()) + .WithName("Mixed") + .ProducesPortableValidationProblem( + configure: x => x + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) + .WithInRangeError() + ); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var responseItems = GetProblemItems(document, "/mixed"); + + responseItems.AnyOf!.Select(static schema => GetSchemaReferenceId((OpenApiSchemaReference) schema)) + .Should() + .Contain( + [ + "PortableError__NotEmpty", + "PortableError__LengthInRange", + "PortableError__Mixed__400__application_problem_json__InRange", + "PortableError" + ] + ); + } + + private static WebApplication CreateTypedHelperApp(string operationName) + { + return CreateApp( + _ => { }, + endpoints => + { + endpoints + .MapGet("/" + operationName.ToLowerInvariant(), static () => Results.Problem()) + .WithName(operationName) + .ProducesPortableValidationProblem( + configure: builder => + { + builder.UseFormat(ValidationProblemSerializationFormat.Rich); + AddTypedHelper(operationName, builder); + } + ); + } + ); + } + + private static void AddTypedHelper(string operationName, PortableValidationProblemOpenApiBuilder builder) + { + switch (operationName) + { + case "EqualTo": + builder.WithEqualToError(); + break; + case "NotEqualTo": + builder.WithNotEqualToError(); + break; + case "GreaterThan": + builder.WithGreaterThanError(); + break; + case "GreaterThanOrEqualTo": + builder.WithGreaterThanOrEqualToError(); + break; + case "LessThan": + builder.WithLessThanError(); + break; + case "LessThanOrEqualTo": + builder.WithLessThanOrEqualToError(); + break; + case "InRange": + case "InRangeDateTime": + builder.WithInRangeError(); + break; + case "NotInRange": + builder.WithNotInRangeError(); + break; + case "ExclusiveRange": + builder.WithExclusiveRangeError(); + break; + default: + throw new InvalidOperationException("Unknown helper: " + operationName); + } + } + + private static WebApplication CreateApp( + Action configureContracts, + Action configureEndpoints, + Action? configureOpenApi = null + ) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMinimalApis(); + builder.Services.AddPortableResultsOpenApi(); + builder.Services.Configure( + options => options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich + ); + builder.Services.ConfigureErrorMetadataContracts(configureContracts); + builder.Services.AddOpenApi(options => configureOpenApi?.Invoke(options)); + + var app = builder.Build(); + configureEndpoints(app); + return app; + } + + private static async Task GetOpenApiDocumentAsync(WebApplication app) + { + await app.StartAsync(TestContext.Current.CancellationToken); + var provider = app.Services.GetRequiredKeyedService("v1"); + return await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken); + } + + private static OpenApiSchema GetProblemItems(OpenApiDocument document, string path) + { + var response = (OpenApiResponse) document.Paths[path] + .Operations![HttpMethod.Get] + .Responses![StatusCodes.Status400BadRequest.ToString(CultureInfo.InvariantCulture)]; + var schema = (OpenApiSchemaReference) response.Content!["application/problem+json"].Schema!; + var component = GetSchemaComponent(document, GetSchemaReferenceId(schema)); + var extension = (OpenApiSchema) component.AllOf![1]; + var propertyName = extension.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; + return (OpenApiSchema) ((OpenApiSchema) extension.Properties[propertyName]).Items!; + } + + private static OpenApiSchema GetCodeSpecificExtension(OpenApiDocument document, string schemaId) + { + return (OpenApiSchema) GetSchemaComponent(document, schemaId).AllOf![1]; + } + + private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) + { + return (OpenApiSchema) document.Components!.Schemas![schemaId]; + } + + private static string GetSchemaReferenceId(OpenApiSchemaReference schemaReference) + { + var referenceId = schemaReference.Reference.Id ?? schemaReference.Id; + referenceId.Should().NotBeNull(); + return referenceId; + } + + private static bool SchemaIncludesType(OpenApiSchema schema, JsonSchemaType type) + { + return schema.Type.HasValue && (schema.Type.Value & type) == type; + } + + private sealed class EquivalentMetadata + { + public int Value { get; init; } + } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json index b809238..18dbe2a 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json @@ -273,6 +273,19 @@ "Light.PortableResults": "[0.4.0, )" } }, + "light.portableresults.validation": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "light.portableresults.validation.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.OpenApi": "[0.4.0, )", + "Light.PortableResults.Validation": "[0.4.0, )" + } + }, "Microsoft.AspNetCore.OpenApi": { "type": "CentralTransitive", "requested": "[10.0.5, )", diff --git a/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs b/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs index 3c10356..3098a6c 100644 --- a/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs @@ -418,7 +418,7 @@ public void HasLengthIn_ShouldAddError_WhenStringIsTooShortForRange() context.Errors.Should().ContainSingle( error => error.Target == "password" && - error.Code == "LengthIn" + error.Code == "LengthInRange" ); } @@ -432,7 +432,7 @@ public void Matches_ShouldAddError_WhenStringDoesNotMatchPattern() context.Errors.Should().ContainSingle( error => error.Target == "digits" && - error.Code == "Matches" + error.Code == "Pattern" ); } @@ -448,7 +448,7 @@ public void Matches_ShouldAddError_WhenStringDoesNotMatchRegex() context.Errors.Should().ContainSingle( error => error.Target == "letters" && - error.Code == "Matches" && + error.Code == "Pattern" && error.Message == "Letters are invalid" ); } diff --git a/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs b/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs index d659fb8..98ed4b4 100644 --- a/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs @@ -296,7 +296,7 @@ public void IsNotIn_ShouldAddError_WhenValueIsInsideRange() context.Check(2, target: "notInRange", displayName: "Not in range").IsNotInBetween(1, 3); - context.Errors.Should().ContainSingle(error => error.Target == "notInRange" && error.Code == "NotInBetween"); + context.Errors.Should().ContainSingle(error => error.Target == "notInRange" && error.Code == "NotInRange"); } [Fact] @@ -416,7 +416,7 @@ public void IsIn_ShouldAddError_WhenValueIsOutsideRange_DefaultOverrides() context.Check(1, target: "inRangeDefault", displayName: "In range default").IsInBetween(2, 3); - context.Errors.Should().ContainSingle(error => error.Target == "inRangeDefault" && error.Code == "IsInBetween"); + context.Errors.Should().ContainSingle(error => error.Target == "inRangeDefault" && error.Code == "InRange"); } [Fact] @@ -867,7 +867,7 @@ public void Matches_ShouldAddError_WhenStringDoesNotMatchRegex_DefaultOverrides( context.Check("12A", target: "regexDefault", displayName: "Regex default").Matches(new Regex("^\\d+$")); - context.Errors.Should().ContainSingle(error => error.Target == "regexDefault" && error.Code == "Matches"); + context.Errors.Should().ContainSingle(error => error.Target == "regexDefault" && error.Code == "Pattern"); } [Fact] diff --git a/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs b/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs index fd7c098..7eb90c3 100644 --- a/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs @@ -1073,7 +1073,7 @@ public void MatchesRegex_ShouldShortCircuit_WhenRequested() var regex = new Regex("^\\d+$"); check.Matches(regex, shortCircuitOnError: true).IsShortCircuited.Should().BeTrue(); - context.Errors.Should().Contain(error => error.Target == "regex" && error.Code == "Matches"); + context.Errors.Should().Contain(error => error.Target == "regex" && error.Code == "Pattern"); } [Fact] @@ -1083,7 +1083,7 @@ public void MatchesPattern_ShouldShortCircuit_WhenRequested() var check = context.Check("abc", target: "pattern", displayName: "Pattern"); check.Matches("^\\d+$", shortCircuitOnError: true).IsShortCircuited.Should().BeTrue(); - context.Errors.Should().Contain(error => error.Target == "pattern" && error.Code == "Matches"); + context.Errors.Should().Contain(error => error.Target == "pattern" && error.Code == "Pattern"); } [Fact] diff --git a/tests/Light.PortableResults.Validation.Tests/ErrorOverridesTests.cs b/tests/Light.PortableResults.Validation.Tests/ErrorOverridesTests.cs index 824da0c..c505346 100644 --- a/tests/Light.PortableResults.Validation.Tests/ErrorOverridesTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ErrorOverridesTests.cs @@ -47,7 +47,7 @@ public void MessageOnlyOverrides_ShouldSupportRepresentativeBuiltInAssertionFami new Error { Message = "Code must contain only digits", - Code = "Matches", + Code = "Pattern", Target = "code", Category = ErrorCategory.Validation, Metadata = MetadataObject.Create( @@ -58,7 +58,7 @@ public void MessageOnlyOverrides_ShouldSupportRepresentativeBuiltInAssertionFami new Error { Message = "Alternate code is invalid", - Code = "Matches", + Code = "Pattern", Target = "alternateCode", Category = ErrorCategory.Validation, Metadata = MetadataObject.Create( diff --git a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs index 637bbbc..fa926f7 100644 --- a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using FluentAssertions; using Light.PortableResults.Metadata; +using Light.PortableResults.Validation; using Light.PortableResults.Validation.Definitions; using Light.PortableResults.Validation.Messaging; using Light.PortableResults.Validation.Targeting; @@ -15,17 +16,26 @@ public sealed class ValidationErrorDefinitionTests [Fact] public void BuiltInDefinitions_ShouldExposeExpectedDefaults() { - BuiltInValidationErrorDefinitions.NotNull.Code.Should().Be("NotNull"); - BuiltInValidationErrorDefinitions.Null.Code.Should().Be("Null"); - BuiltInValidationErrorDefinitions.Empty.Code.Should().Be("Empty"); - BuiltInValidationErrorDefinitions.NotEmpty.Code.Should().Be("NotEmpty"); - BuiltInValidationErrorDefinitions.NotNullOrWhiteSpace.Code.Should().Be("NotNullOrWhiteSpace"); - BuiltInValidationErrorDefinitions.Email.Code.Should().Be("Email"); - BuiltInValidationErrorDefinitions.Predicate.Code.Should().Be("Predicate"); + BuiltInValidationErrorDefinitions.NotNull.Code.Should().Be(ValidationErrorCodes.NotNull); + BuiltInValidationErrorDefinitions.Null.Code.Should().Be(ValidationErrorCodes.Null); + BuiltInValidationErrorDefinitions.Empty.Code.Should().Be(ValidationErrorCodes.Empty); + BuiltInValidationErrorDefinitions.NotEmpty.Code.Should().Be(ValidationErrorCodes.NotEmpty); + BuiltInValidationErrorDefinitions.NotNullOrWhiteSpace.Code.Should().Be(ValidationErrorCodes.NotNullOrWhiteSpace); + BuiltInValidationErrorDefinitions.Email.Code.Should().Be(ValidationErrorCodes.Email); + BuiltInValidationErrorDefinitions.Predicate.Code.Should().Be(ValidationErrorCodes.Predicate); BuiltInValidationErrorDefinitions.NotNull.Metadata.Should().BeNull(); BuiltInValidationErrorDefinitions.Email.Metadata.Should().BeNull(); } + [Fact] + public void BuiltInDefinitions_ShouldEmitRenamedRuntimeCodes() + { + BuiltInValidationErrorDefinitions.LengthIn(2, 5).Code.Should().Be(ValidationErrorCodes.LengthInRange); + BuiltInValidationErrorDefinitions.Matches("^[0-9]+$").Code.Should().Be(ValidationErrorCodes.Pattern); + BuiltInValidationErrorDefinitions.IsInBetween(1, 5).Code.Should().Be(ValidationErrorCodes.InRange); + BuiltInValidationErrorDefinitions.IsNotInBetween(1, 5).Code.Should().Be(ValidationErrorCodes.NotInRange); + } + [Fact] public void EqualTo_ShouldExposeExpectedMetadata() { From 722a40c78baf2cdce22321d8fd83197ed6503215 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 20:11:39 +0200 Subject: [PATCH 41/67] chore: BuiltInValidationErrorContracts now uses FrozenDictionary Signed-off-by: Kenny Pflug --- .../BuiltInValidationErrorContracts.cs | 25 ++++++---- .../BuiltInValidationErrorContractsTests.cs | 48 +++++++++++-------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs index 5dc610e..6c5c2ea 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Frozen; using System.Collections.Generic; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Light.PortableResults.Validation.Definitions; @@ -14,9 +15,9 @@ public static class BuiltInValidationErrorContracts /// /// Gets the built-in validation error metadata contracts. /// - public static IReadOnlyDictionary Contracts { get; } = CreateContracts(); + public static FrozenDictionary Contracts { get; } = CreateContracts(); - private static IReadOnlyDictionary CreateContracts() + private static FrozenDictionary CreateContracts() { return new Dictionary(StringComparer.Ordinal) { @@ -34,12 +35,18 @@ private static IReadOnlyDictionary Create } ) ), - [ValidationErrorCodes.EqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.NotEqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.GreaterThan] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.GreaterThanOrEqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.LessThan] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.LessThanOrEqualTo] = Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.EqualTo] = + Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.NotEqualTo] = + Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.GreaterThan] = + Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.GreaterThanOrEqualTo] = + Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.LessThan] = + Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), + [ValidationErrorCodes.LessThanOrEqualTo] = + Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), [ValidationErrorCodes.InRange] = Schema(ObjectWithPrimitiveRange()), [ValidationErrorCodes.NotInRange] = Schema(ObjectWithPrimitiveRange()), [ValidationErrorCodes.ExclusiveRange] = Schema(ObjectWithPrimitiveRange()), @@ -80,7 +87,7 @@ private static IReadOnlyDictionary Create [ValidationErrorCodes.Email] = PortableErrorMetadataContract.NoMetadata, [ValidationErrorCodes.DigitsOnly] = PortableErrorMetadataContract.NoMetadata, [ValidationErrorCodes.LettersAndDigitsOnly] = PortableErrorMetadataContract.NoMetadata - }; + }.ToFrozenDictionary(StringComparer.Ordinal); } private static PortableErrorMetadataContract Schema(Func schemaFactory) => diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index c7dec59..5924d3a 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Frozen; using System.Linq; using FluentAssertions; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -66,9 +65,15 @@ public sealed class BuiltInValidationErrorContractsTests ValidationErrorCodes.ExclusiveRange, [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] }, - { ValidationErrorCodes.Pattern, [ValidationErrorMetadataKeys.Pattern, ValidationErrorMetadataKeys.RegexOptions] }, + { + ValidationErrorCodes.Pattern, + [ValidationErrorMetadataKeys.Pattern, ValidationErrorMetadataKeys.RegexOptions] + }, { ValidationErrorCodes.Enum, [ValidationErrorMetadataKeys.EnumType] }, - { ValidationErrorCodes.EnumName, [ValidationErrorMetadataKeys.EnumType, ValidationErrorMetadataKeys.IgnoreCase] }, + { + ValidationErrorCodes.EnumName, + [ValidationErrorMetadataKeys.EnumType, ValidationErrorMetadataKeys.IgnoreCase] + }, { ValidationErrorCodes.PrecisionScale, [ @@ -80,18 +85,17 @@ public sealed class BuiltInValidationErrorContractsTests }; public static TheoryData PrimitiveValueCodes => - new () - { - ValidationErrorCodes.EqualTo, - ValidationErrorCodes.NotEqualTo, - ValidationErrorCodes.GreaterThan, - ValidationErrorCodes.GreaterThanOrEqualTo, - ValidationErrorCodes.LessThan, - ValidationErrorCodes.LessThanOrEqualTo, - ValidationErrorCodes.InRange, - ValidationErrorCodes.NotInRange, - ValidationErrorCodes.ExclusiveRange - }; + [ + ValidationErrorCodes.EqualTo, + ValidationErrorCodes.NotEqualTo, + ValidationErrorCodes.GreaterThan, + ValidationErrorCodes.GreaterThanOrEqualTo, + ValidationErrorCodes.LessThan, + ValidationErrorCodes.LessThanOrEqualTo, + ValidationErrorCodes.InRange, + ValidationErrorCodes.NotInRange, + ValidationErrorCodes.ExclusiveRange + ]; [Fact] public void Contracts_ShouldContainExpectedBuiltInCodes() @@ -111,14 +115,16 @@ public void Contracts_ShouldContainExpectedBuiltInCodes() BuiltInValidationErrorContracts.Contracts.Keys.Should() .BeEquivalentTo(MetadataBearingCodes.Concat(expectedNoMetadataCodes)); BuiltInValidationErrorContracts.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); - expectedNoMetadataCodes.Should().OnlyContain( - code => ReferenceEquals( - BuiltInValidationErrorContracts.Contracts[code], - PortableErrorMetadataContract.NoMetadata - ) + BuiltInValidationErrorContracts.Contracts.Values.Should().AllSatisfy( + x => ReferenceEquals(x, PortableErrorMetadataContract.NoMetadata) ); } + [Fact] + public void Contracts_ShouldBeBackedByFrozenDictionary() => + BuiltInValidationErrorContracts.Contracts.Should() + .BeAssignableTo>(); + [Theory] [MemberData(nameof(MetadataCodeProperties))] public void MetadataContracts_ShouldEmitExpectedObjectProperties(string code, string[] expectedProperties) From 534bb44f09b17c88af531c3948cca9a73ea60111 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 21:13:36 +0200 Subject: [PATCH 42/67] chore: introduce diagnostic name for schema-based error metadata contracts Signed-off-by: Kenny Pflug --- .../PortableErrorMetadataContract.cs | 58 +++++++++++- .../PortableErrorMetadataContractsBuilder.cs | 9 +- .../PortableResultsOpenApiMessages.cs | 13 +-- .../BuiltInValidationErrorContracts.cs | 88 ++++++++++++++----- .../PortableErrorMetadataContractTests.cs | 52 ++++++++++- 5 files changed, 181 insertions(+), 39 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs index 2701a89..bdc61d8 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using Microsoft.OpenApi; namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -32,11 +33,15 @@ public static PortableErrorMetadataContract FromType(Type metadataType) /// Creates a contract backed by a schema factory. /// /// The factory that creates a fresh metadata schema for the requested OpenAPI version. + /// The optional diagnostic name used in duplicate-contract errors. /// The metadata contract. - public static PortableErrorMetadataContract FromSchema(Func schemaFactory) + public static PortableErrorMetadataContract FromSchema( + Func schemaFactory, + [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + ) { ArgumentNullException.ThrowIfNull(schemaFactory); - return new PortableErrorMetadataSchemaContract(schemaFactory); + return new PortableErrorMetadataSchemaContract(schemaFactory, diagnosticName); } } @@ -70,16 +75,63 @@ public sealed class PortableErrorMetadataSchemaContract : PortableErrorMetadataC /// Initializes a new instance of . /// /// The factory that creates a fresh metadata schema for the requested OpenAPI version. - public PortableErrorMetadataSchemaContract(Func schemaFactory) + /// The optional diagnostic name used in duplicate-contract errors. + public PortableErrorMetadataSchemaContract( + Func schemaFactory, + [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + ) { ArgumentNullException.ThrowIfNull(schemaFactory); SchemaFactory = schemaFactory; + DiagnosticName = CreateDiagnosticName(schemaFactory, diagnosticName); } /// /// Gets the factory that creates a fresh metadata schema for the requested OpenAPI version. /// public Func SchemaFactory { get; } + + /// + /// Gets the diagnostic name used in duplicate-contract errors. + /// + public string DiagnosticName { get; } + + private static string CreateDiagnosticName( + Func schemaFactory, + string? diagnosticName + ) + { + if (!string.IsNullOrWhiteSpace(diagnosticName)) + { + return diagnosticName; + } + + var method = schemaFactory.Method; + var methodName = method.Name; + var declaringTypeName = method.DeclaringType?.FullName ?? method.DeclaringType?.Name; + if (string.IsNullOrWhiteSpace(methodName) || methodName.Contains("<", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "A schema-based error metadata contract requires a meaningful diagnostic name. " + + "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." + ); + } + + if (!string.IsNullOrWhiteSpace(declaringTypeName) && !string.IsNullOrWhiteSpace(methodName)) + { + return declaringTypeName + "." + methodName; + } + + if (!string.IsNullOrWhiteSpace(methodName)) + { + return methodName; + } + + throw new InvalidOperationException( + "A schema-based error metadata contract requires a meaningful diagnostic name. " + + "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." + ); + } } /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs index 736050b..494949d 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Light.PortableResults.AspNetCore.OpenApi.Generation; using Light.PortableResults.AspNetCore.OpenApi.Schemas; using Microsoft.OpenApi; @@ -35,12 +36,16 @@ public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataT /// /// Registers the specified OpenAPI metadata schema factory for the specified code. /// + /// The error code to register. + /// The factory that creates a fresh metadata schema for the requested OpenAPI version. + /// The optional diagnostic name used in duplicate-contract errors. public PortableErrorMetadataContractsBuilder ForCode( string code, - Func metadataSchemaFactory + Func metadataSchemaFactory, + [CallerArgumentExpression(nameof(metadataSchemaFactory))] string? diagnosticName = null ) { - return ForCode(code, PortableErrorMetadataContract.FromSchema(metadataSchemaFactory)); + return ForCode(code, PortableErrorMetadataContract.FromSchema(metadataSchemaFactory, diagnosticName)); } /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index 7b93c7e..197aee6 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -1,17 +1,9 @@ -using System; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; namespace Light.PortableResults.AspNetCore.OpenApi.Generation; internal static class PortableResultsOpenApiMessages { - internal static string CreateDuplicateErrorMetadataContractMessage( - string code, - Type existingType, - Type newType - ) => - $"The error code '{code}' is already registered with metadata type '{existingType.FullName}'. It cannot also be registered with '{newType.FullName}'."; - internal static string CreateDuplicateErrorMetadataContractMessage( string code, PortableErrorMetadataContract existingContract, @@ -38,9 +30,12 @@ private static string DescribeContract(PortableErrorMetadataContract contract) { PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.FullName ?? typeContract.MetadataType.Name, - PortableErrorMetadataSchemaContract => "schema factory", + PortableErrorMetadataSchemaContract schemaContract => DescribeSchemaFactory(schemaContract), PortableErrorMetadataNoMetadataContract => "no metadata", _ => contract.GetType().FullName ?? contract.GetType().Name }; } + + private static string DescribeSchemaFactory(PortableErrorMetadataSchemaContract schemaContract) + => "schema factory " + schemaContract.DiagnosticName; } diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs index 6c5c2ea..5e49700 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -21,12 +21,28 @@ private static FrozenDictionary CreateCon { return new Dictionary(StringComparer.Ordinal) { - [ValidationErrorCodes.Count] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.ExpectedCount)), - [ValidationErrorCodes.MinCount] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MinCount)), - [ValidationErrorCodes.MaxCount] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MaxCount)), - [ValidationErrorCodes.MinLength] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MinLength)), - [ValidationErrorCodes.MaxLength] = Schema(ObjectWithInteger(ValidationErrorMetadataKeys.MaxLength)), + [ValidationErrorCodes.Count] = Schema( + ValidationErrorCodes.Count, + ObjectWithInteger(ValidationErrorMetadataKeys.ExpectedCount) + ), + [ValidationErrorCodes.MinCount] = Schema( + ValidationErrorCodes.MinCount, + ObjectWithInteger(ValidationErrorMetadataKeys.MinCount) + ), + [ValidationErrorCodes.MaxCount] = Schema( + ValidationErrorCodes.MaxCount, + ObjectWithInteger(ValidationErrorMetadataKeys.MaxCount) + ), + [ValidationErrorCodes.MinLength] = Schema( + ValidationErrorCodes.MinLength, + ObjectWithInteger(ValidationErrorMetadataKeys.MinLength) + ), + [ValidationErrorCodes.MaxLength] = Schema( + ValidationErrorCodes.MaxLength, + ObjectWithInteger(ValidationErrorMetadataKeys.MaxLength) + ), [ValidationErrorCodes.LengthInRange] = Schema( + ValidationErrorCodes.LengthInRange, _ => CreateObjectSchema( new Dictionary(StringComparer.Ordinal) { @@ -35,22 +51,41 @@ private static FrozenDictionary CreateCon } ) ), - [ValidationErrorCodes.EqualTo] = - Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.NotEqualTo] = - Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.GreaterThan] = - Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.GreaterThanOrEqualTo] = - Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.LessThan] = - Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.LessThanOrEqualTo] = - Schema(ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue)), - [ValidationErrorCodes.InRange] = Schema(ObjectWithPrimitiveRange()), - [ValidationErrorCodes.NotInRange] = Schema(ObjectWithPrimitiveRange()), - [ValidationErrorCodes.ExclusiveRange] = Schema(ObjectWithPrimitiveRange()), + [ValidationErrorCodes.EqualTo] = Schema( + ValidationErrorCodes.EqualTo, + ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue) + ), + [ValidationErrorCodes.NotEqualTo] = Schema( + ValidationErrorCodes.NotEqualTo, + ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue) + ), + [ValidationErrorCodes.GreaterThan] = Schema( + ValidationErrorCodes.GreaterThan, + ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue) + ), + [ValidationErrorCodes.GreaterThanOrEqualTo] = Schema( + ValidationErrorCodes.GreaterThanOrEqualTo, + ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue) + ), + [ValidationErrorCodes.LessThan] = Schema( + ValidationErrorCodes.LessThan, + ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue) + ), + [ValidationErrorCodes.LessThanOrEqualTo] = Schema( + ValidationErrorCodes.LessThanOrEqualTo, + ObjectWithPrimitiveValue(ValidationErrorMetadataKeys.ComparativeValue) + ), + [ValidationErrorCodes.InRange] = Schema(ValidationErrorCodes.InRange, ObjectWithPrimitiveRange()), + [ValidationErrorCodes.NotInRange] = Schema( + ValidationErrorCodes.NotInRange, + ObjectWithPrimitiveRange() + ), + [ValidationErrorCodes.ExclusiveRange] = Schema( + ValidationErrorCodes.ExclusiveRange, + ObjectWithPrimitiveRange() + ), [ValidationErrorCodes.Pattern] = Schema( + ValidationErrorCodes.Pattern, _ => CreateObjectSchema( new Dictionary(StringComparer.Ordinal) { @@ -59,8 +94,12 @@ private static FrozenDictionary CreateCon } ) ), - [ValidationErrorCodes.Enum] = Schema(ObjectWithString(ValidationErrorMetadataKeys.EnumType)), + [ValidationErrorCodes.Enum] = Schema( + ValidationErrorCodes.Enum, + ObjectWithString(ValidationErrorMetadataKeys.EnumType) + ), [ValidationErrorCodes.EnumName] = Schema( + ValidationErrorCodes.EnumName, _ => CreateObjectSchema( new Dictionary(StringComparer.Ordinal) { @@ -70,6 +109,7 @@ private static FrozenDictionary CreateCon ) ), [ValidationErrorCodes.PrecisionScale] = Schema( + ValidationErrorCodes.PrecisionScale, _ => CreateObjectSchema( new Dictionary(StringComparer.Ordinal) { @@ -90,8 +130,10 @@ private static FrozenDictionary CreateCon }.ToFrozenDictionary(StringComparer.Ordinal); } - private static PortableErrorMetadataContract Schema(Func schemaFactory) => - PortableErrorMetadataContract.FromSchema(schemaFactory); + private static PortableErrorMetadataContract Schema( + string code, + Func schemaFactory + ) => PortableErrorMetadataContract.FromSchema(schemaFactory, "built-in validation schema for " + code); private static Func ObjectWithInteger(string propertyName) => _ => CreateObjectSchema( diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs index b9078f8..4a153fd 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -22,6 +22,7 @@ public void ContractFactories_ShouldExposeClosedSubclassPayloads() .Which.MetadataType.Should().Be(typeof(TestMetadata)); schemaContract.Should().BeOfType() .Which.SchemaFactory.Should().BeSameAs(schemaFactory); + ((PortableErrorMetadataSchemaContract) schemaContract).DiagnosticName.Should().Be(nameof(schemaFactory)); noMetadata.Should().BeOfType(); PortableErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); typeof(PortableErrorMetadataContract) @@ -29,7 +30,7 @@ public void ContractFactories_ShouldExposeClosedSubclassPayloads() .Should() .BeEmpty(); - string discriminator = typeContract switch + var discriminator = typeContract switch { PortableErrorMetadataTypeContract => "type", PortableErrorMetadataSchemaContract => "schema", @@ -56,6 +57,39 @@ public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode", "SchemaCode", "NoMetadataCode"); } + [Fact] + public void SchemaContracts_ShouldAllowExplicitDiagnosticNames() + { + Func schemaFactory = _ => new OpenApiSchema(); + + var schemaContract = PortableErrorMetadataContract.FromSchema(schemaFactory, "named schema"); + + schemaContract.Should().BeOfType() + .Which.DiagnosticName.Should().Be("named schema"); + } + + [Fact] + public void SchemaContracts_ShouldDeriveDiagnosticNamesFromMethodMetadata_WhenNoNameIsAvailable() + { + var schemaContract = new PortableErrorMetadataSchemaContract(CreateSchema, null); + + schemaContract.DiagnosticName.Should().Contain(nameof(CreateSchema)); + } + + [Fact] + public void SchemaContracts_ShouldThrow_WhenNoMeaningfulDiagnosticNameCanBeDerived() + { + new Action( + () => PortableErrorMetadataContract.FromSchema( + _ => new OpenApiSchema(), + null + ) + ) + .Should() + .Throw() + .WithMessage("*meaningful diagnostic name*diagnosticName*"); + } + [Fact] public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() { @@ -78,7 +112,16 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() ) .Should() .Throw() - .WithMessage("*Conflict*schema factory*"); + .WithMessage("*Conflict*firstFactory*secondFactory*"); + + new Action( + () => new PortableErrorMetadataContractsBuilder() + .ForCode("Conflict", firstFactory, "first schema") + .ForCode("Conflict", secondFactory, "second schema") + ) + .Should() + .Throw() + .WithMessage("*Conflict*first schema*second schema*"); new Action( () => new PortableErrorMetadataContractsBuilder() @@ -101,13 +144,18 @@ public void ContractRegistry_ShouldExposePortableMetadataContracts() registry.Contracts["TypeCode"].Should().BeOfType(); } + // ReSharper disable UnusedMember.Local -- required for testing private sealed class TestMetadata { public string Value { get; init; } = string.Empty; } + // ReSharper disable once ClassNeverInstantiated.Local -- required for testing private sealed class OtherMetadata { public string Value { get; init; } = string.Empty; } + + private static OpenApiSchema CreateSchema(OpenApiSpecVersion _) => new (); + // ReSharper restore UnusedMember.Local } From 800fbd428d11500638e6cb1fd415f6a4fff21c50 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 21:36:18 +0200 Subject: [PATCH 43/67] chore: add 0040-3 plan for improved OpenAPI test coverage Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0040-3-openapi-test-coverage.md | 56 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 ai-plans/0040-3-openapi-test-coverage.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 7f4c84f..3d12854 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -52,6 +52,7 @@ + diff --git a/ai-plans/0040-3-openapi-test-coverage.md b/ai-plans/0040-3-openapi-test-coverage.md new file mode 100644 index 0000000..b3fbb38 --- /dev/null +++ b/ai-plans/0040-3-openapi-test-coverage.md @@ -0,0 +1,56 @@ +# Improve OpenAPI Test Coverage + +## Rationale + +The OpenAPI work is currently covered by a single test project, `Light.PortableResults.AspNetCore.OpenApi.Tests`, even though the production code is split across `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi`. This makes ownership blurry, leaves public workflows in the validation bridge under-tested, and makes it too easy to miss gaps in package-specific coverage. Coverage feedback also needs to be gathered with `coverage.runsettings`; otherwise generated `obj/**` OpenAPI source-generator files distort the numbers and hide the real gaps. With `coverage.runsettings` applied, the current line-coverage baselines are approximately **88.8%** for `Light.PortableResults.AspNetCore.OpenApi` and **83.2%** for `Light.PortableResults.Validation.OpenApi`. + +This plan reorganizes the tests around package boundaries and public behavior. The primary goal is to add a dedicated `Light.PortableResults.Validation.OpenApi.Tests` project, move validation-specific OpenAPI tests there, and expand both suites with sociable unit tests that exercise realistic in-memory ASP.NET Core/OpenAPI flows before adding any narrowly scoped lower-level tests. + +## Acceptance Criteria + +- [ ] Coverage work for the OpenAPI packages is measured with `coverlet.collector` together with `coverage.runsettings`, so generated `obj/**` files do not distort the feedback loop for `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi`. +- [ ] A new test project `Light.PortableResults.Validation.OpenApi.Tests` is added to the solution, references the validation OpenAPI bridge package and its required runtime collaborators, includes `coverlet.collector`, and follows the repository testing conventions. +- [ ] Validation-specific OpenAPI tests are moved or rewritten so that `Light.PortableResults.AspNetCore.OpenApi.Tests` focuses on the generic OpenAPI package while `Light.PortableResults.Validation.OpenApi.Tests` owns the validation bridge package. +- [ ] The validation OpenAPI test suite covers the public workflows of `BuiltInValidationErrorContracts`, `RegisterBuiltInValidationErrors`, and the typed `WithEqualToError`, `WithNotEqualToError`, `WithGreaterThanError`, `WithGreaterThanOrEqualToError`, `WithLessThanError`, `WithLessThanOrEqualToError`, `WithInRangeError`, `WithNotInRangeError`, and `WithExclusiveRangeError` helpers on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`. +- [ ] The generic OpenAPI test suite gains additional coverage for the currently under-covered public behavior in `PortableErrorMetadataContractRegistry`, `PortableOpenApiBuilderUtilities`, `PortableErrorMetadataContractEqualityComparer`, and the response-builder flows that rely on them, preferring sociable tests and using focused lower-level tests only where the behavior is difficult to reach from the outside. +- [ ] The reorganized suites keep their focus on sociable unit tests built around real builders, real attributes, real registries, and in-memory OpenAPI document generation, and they do not introduce mocking libraries or solitary test patterns. +- [ ] Automated tests are updated and expanded as needed, and the resulting **line coverage** for both `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi` exceeds 92% when measured with `coverage.runsettings`, without padding the suite with low-value constructor-only or reflection-heavy tests. + +## Technical Details + +Treat this as a test-architecture refinement, not as a runtime package redesign. `Light.PortableResults.Validation.OpenApi` already exists and should remain the runtime home for built-in validation error contracts and typed validation-specific OpenAPI helpers. The missing piece is an equally clear test boundary. Add `tests/Light.PortableResults.Validation.OpenApi.Tests` as the dedicated home for validation bridge tests, wire it into the solution, and give it the same basic setup as the other .NET 10 xUnit v3 test projects in the repository. + +The existing `Light.PortableResults.AspNetCore.OpenApi.Tests` project should then be narrowed to the generic package. `BuiltInValidationErrorContractsTests` and `ValidationOpenApiDocumentTransformerTests` are the obvious starting points to move or rewrite in the new project because they primarily verify behavior from `Light.PortableResults.Validation.OpenApi`. After the split, the generic test project should mostly own: + +- OpenAPI document transformer behavior that belongs to the core OpenAPI package +- generic error-contract registry and duplicate-detection behavior +- generic schema naming and schema catalog behavior +- generic route-handler and attribute-driven response documentation behavior + +The new validation OpenAPI test project should own the validation bridge behaviors and keep them sociable. Prefer the style already used in the current document-transformer tests: create a minimal in-memory ASP.NET Core application with the real production service registrations, configure endpoints with real `ProducesPortableProblem(...)` / `ProducesPortableValidationProblem(...)` calls, generate an OpenAPI document through `IOpenApiDocumentProvider`, and assert the produced schemas. This keeps the tests close to how consumers actually use the packages and naturally covers multiple collaborators at once. + +Structure the validation bridge tests around a few public workflows instead of many tiny helper-level assertions. A good division is: + +- one fixture for the built-in contract catalog and registration extension +- one fixture for typed comparison and range helpers on `PortableProblemOpenApiBuilder` +- one fixture for the same helpers on `PortableValidationProblemOpenApiBuilder` +- one or two end-to-end document-generation fixtures that mix global built-in contracts with endpoint-scoped typed narrowing + +The typed-helper coverage should be matrix-based rather than copy-pasted. Reuse theory data to drive the helper name, validation error code, and expected metadata properties so the tests stay compact while still covering every helper. Cover representative type arguments such as `int` and `DateTime` to prove that the endpoint-scoped narrowing continues to flow through the standard ASP.NET Core schema generator. + +For the generic OpenAPI package, prefer outside-in coverage first and only add focused lower-level tests for branches that remain uncovered after the sociable tests are in place. In practice, this means: + +- add scenarios that exercise array-appending and type-appending behavior through the real response builders instead of testing `PortableOpenApiBuilderUtilities` in isolation wherever possible +- add registry scenarios that prove duplicate equivalent contracts are accepted, conflicting contracts are rejected, and sanitized-code collisions are rejected +- add small focused tests for `PortableErrorMetadataContractEqualityComparer` only if some of its branch behavior remains unreachable through the public builder/registry contract + +Do not chase percentages with passive tests that only instantiate records in `BuiltInValidationErrorMetadata.cs` or reflect over API shape without protecting meaningful behavior. If some of those types remain under-covered after the sociable tests are complete, prefer one meaningful public-contract test that causes them to participate in real document generation over a set of constructor-only tests. Only keep narrowly scoped tests where the observable public contract would otherwise be hard to protect. + +Use coverage as a feedback loop throughout the work, but always run it with the repository runsettings so generated files are excluded. The expected command pattern is: + +```bash +dotnet test tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj --settings coverage.runsettings --collect:"XPlat Code Coverage" +dotnet test tests/Light.PortableResults.Validation.OpenApi.Tests/Light.PortableResults.Validation.OpenApi.Tests.csproj --settings coverage.runsettings --collect:"XPlat Code Coverage" +``` + +The first goal of the coverage pass is to confirm that package-specific gaps are shrinking after the sociable tests are added and that both OpenAPI packages clear the 92% line-coverage target with generated files excluded. Only after that should additional focused tests be introduced for the remaining uncovered public branches. The finished test layout should make package ownership obvious, keep the validation bridge tests close to the bridge package, and leave both OpenAPI suites easier to extend when new OpenAPI helpers or validation contracts are added. From ffa806cdab6ba9bc63c7253cabe8c763757767f8 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 22:03:21 +0200 Subject: [PATCH 44/67] test: increase code coverage for OpenAPI functionality Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0040-3-openapi-test-coverage.md | 14 +- ...bleResults.AspNetCore.OpenApi.Tests.csproj | 1 - .../PortableErrorMetadataContractTests.cs | 35 ++ .../PortableOpenApiResponseBuilderTests.cs | 215 ++++++++++ ...lidationOpenApiDocumentTransformerTests.cs | 396 ------------------ .../BuiltInValidationErrorContractsTests.cs | 41 +- .../BuiltInValidationErrorMetadataTests.cs | 59 +++ ...bleResults.Validation.OpenApi.Tests.csproj | 32 ++ .../PortableProblemOpenApiBuilderTests.cs | 119 ++++++ ...bleValidationProblemOpenApiBuilderTests.cs | 218 ++++++++++ .../ValidationOpenApiDocumentTestUtilities.cs | 78 ++++ .../ValidationTypedHelperTestData.cs | 117 ++++++ .../packages.lock.json | 312 ++++++++++++++ .../xunit.runner.json | 3 + 15 files changed, 1231 insertions(+), 410 deletions(-) create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs delete mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs rename tests/{Light.PortableResults.AspNetCore.OpenApi.Tests => Light.PortableResults.Validation.OpenApi.Tests}/BuiltInValidationErrorContractsTests.cs (86%) create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/Light.PortableResults.Validation.OpenApi.Tests.csproj create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationTypedHelperTestData.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/packages.lock.json create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/xunit.runner.json diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 3d12854..2da6e6b 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -88,6 +88,7 @@ + diff --git a/ai-plans/0040-3-openapi-test-coverage.md b/ai-plans/0040-3-openapi-test-coverage.md index b3fbb38..a83d951 100644 --- a/ai-plans/0040-3-openapi-test-coverage.md +++ b/ai-plans/0040-3-openapi-test-coverage.md @@ -8,13 +8,13 @@ This plan reorganizes the tests around package boundaries and public behavior. T ## Acceptance Criteria -- [ ] Coverage work for the OpenAPI packages is measured with `coverlet.collector` together with `coverage.runsettings`, so generated `obj/**` files do not distort the feedback loop for `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi`. -- [ ] A new test project `Light.PortableResults.Validation.OpenApi.Tests` is added to the solution, references the validation OpenAPI bridge package and its required runtime collaborators, includes `coverlet.collector`, and follows the repository testing conventions. -- [ ] Validation-specific OpenAPI tests are moved or rewritten so that `Light.PortableResults.AspNetCore.OpenApi.Tests` focuses on the generic OpenAPI package while `Light.PortableResults.Validation.OpenApi.Tests` owns the validation bridge package. -- [ ] The validation OpenAPI test suite covers the public workflows of `BuiltInValidationErrorContracts`, `RegisterBuiltInValidationErrors`, and the typed `WithEqualToError`, `WithNotEqualToError`, `WithGreaterThanError`, `WithGreaterThanOrEqualToError`, `WithLessThanError`, `WithLessThanOrEqualToError`, `WithInRangeError`, `WithNotInRangeError`, and `WithExclusiveRangeError` helpers on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`. -- [ ] The generic OpenAPI test suite gains additional coverage for the currently under-covered public behavior in `PortableErrorMetadataContractRegistry`, `PortableOpenApiBuilderUtilities`, `PortableErrorMetadataContractEqualityComparer`, and the response-builder flows that rely on them, preferring sociable tests and using focused lower-level tests only where the behavior is difficult to reach from the outside. -- [ ] The reorganized suites keep their focus on sociable unit tests built around real builders, real attributes, real registries, and in-memory OpenAPI document generation, and they do not introduce mocking libraries or solitary test patterns. -- [ ] Automated tests are updated and expanded as needed, and the resulting **line coverage** for both `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi` exceeds 92% when measured with `coverage.runsettings`, without padding the suite with low-value constructor-only or reflection-heavy tests. +- [x] Coverage work for the OpenAPI packages is measured with `coverlet.collector` together with `coverage.runsettings`, so generated `obj/**` files do not distort the feedback loop for `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi`. +- [x] A new test project `Light.PortableResults.Validation.OpenApi.Tests` is added to the solution, references the validation OpenAPI bridge package and its required runtime collaborators, includes `coverlet.collector`, and follows the repository testing conventions. +- [x] Validation-specific OpenAPI tests are moved or rewritten so that `Light.PortableResults.AspNetCore.OpenApi.Tests` focuses on the generic OpenAPI package while `Light.PortableResults.Validation.OpenApi.Tests` owns the validation bridge package. +- [x] The validation OpenAPI test suite covers the public workflows of `BuiltInValidationErrorContracts`, `RegisterBuiltInValidationErrors`, and the typed `WithEqualToError`, `WithNotEqualToError`, `WithGreaterThanError`, `WithGreaterThanOrEqualToError`, `WithLessThanError`, `WithLessThanOrEqualToError`, `WithInRangeError`, `WithNotInRangeError`, and `WithExclusiveRangeError` helpers on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`. +- [x] The generic OpenAPI test suite gains additional coverage for the currently under-covered public behavior in `PortableErrorMetadataContractRegistry`, `PortableOpenApiBuilderUtilities`, `PortableErrorMetadataContractEqualityComparer`, and the response-builder flows that rely on them, preferring sociable tests and using focused lower-level tests only where the behavior is difficult to reach from the outside. +- [x] The reorganized suites keep their focus on sociable unit tests built around real builders, real attributes, real registries, and in-memory OpenAPI document generation, and they do not introduce mocking libraries or solitary test patterns. +- [x] Automated tests are updated and expanded as needed, and the resulting **line coverage** for both `Light.PortableResults.AspNetCore.OpenApi` and `Light.PortableResults.Validation.OpenApi` exceeds 92% when measured with `coverage.runsettings`, without padding the suite with low-value constructor-only or reflection-heavy tests. ## Technical Details diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj index 23431e2..dc62581 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/Light.PortableResults.AspNetCore.OpenApi.Tests.csproj @@ -27,7 +27,6 @@ - diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs index 4a153fd..f0cffe7 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using FluentAssertions; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Microsoft.OpenApi; @@ -144,6 +145,40 @@ public void ContractRegistry_ShouldExposePortableMetadataContracts() registry.Contracts["TypeCode"].Should().BeOfType(); } + [Fact] + public void ContractRegistry_ShouldSnapshotBuilderState() + { + var builder = new PortableErrorMetadataContractsBuilder().ForCode("TypeCode"); + + var registry = new PortableErrorMetadataContractRegistry(builder); + builder.ForCode("OtherCode"); + + registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode"); + } + + [Fact] + public void ContractRegistry_ShouldRejectSanitizedCodeCollisions_WhenBuilderStateIsComposedExternally() + { + var builder = new PortableErrorMetadataContractsBuilder(); + var contractsField = typeof(PortableErrorMetadataContractsBuilder).GetField( + "_contracts", + BindingFlags.Instance | BindingFlags.NonPublic + ); + contractsField.Should().NotBeNull(); + + var contracts = contractsField + .GetValue(builder) + .Should() + .BeOfType>() + .Subject; + contracts.Add("Code/One", PortableErrorMetadataContract.NoMetadata); + contracts.Add("Code_One", PortableErrorMetadataContract.NoMetadata); + + var act = () => _ = new PortableErrorMetadataContractRegistry(builder); + + act.Should().Throw().WithMessage("*Code/One*Code_One*"); + } + // ReSharper disable UnusedMember.Local -- required for testing private sealed class TestMetadata { diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs new file mode 100644 index 0000000..140ea6d --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.SharedJsonSerialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class PortableOpenApiResponseBuilderTests +{ + [Fact] + public void ProducesPortableProblem_ShouldAccumulateRouteMetadata() + { + var attribute = GetMetadata( + static builder => + builder.ProducesPortableProblem( + StatusCodes.Status404NotFound, + configure: builder => builder + .WithErrorCodes("First") + .WithErrorCodes("Second", "Third") + .WithMetadata() + .WithMetadata(typeof(ProblemMetadata)) + .WithErrorMetadata("Movie/Gone") + .WithErrorMetadata("Movie/Archived", typeof(ProblemMetadata)) + ), + static () => TypedResults.Problem() + ); + + attribute.StatusCode.Should().Be(StatusCodes.Status404NotFound); + attribute.TopLevelMetadataType.Should().Be(typeof(ProblemMetadata)); + attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); + attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); + attribute.InlineErrorMetadataTypes.Should().Equal(typeof(InlineProblemMetadata), typeof(ProblemMetadata)); + } + + [Fact] + public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() + { + var attribute = GetMetadata( + static builder => + builder.ProducesPortableValidationProblem( + configure: x => x.WithErrorCodes("First") + .WithErrorCodes() + .WithErrorCodes("Second", "Third") + .WithMetadata() + .WithMetadata(typeof(ProblemMetadata)) + .WithErrorMetadata("Movie/Gone") + .WithErrorMetadata("Movie/Archived", typeof(ProblemMetadata)) + .UseFormat(ValidationProblemSerializationFormat.Rich) + ), + static () => TypedResults.Problem() + ); + + attribute.TopLevelMetadataType.Should().Be(typeof(ProblemMetadata)); + attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); + attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); + attribute.InlineErrorMetadataTypes.Should().Equal(typeof(InlineProblemMetadata), typeof(ProblemMetadata)); + attribute.Format.Should().Be(ValidationProblemSerializationFormat.Rich); + attribute.HasFormatOverride.Should().BeTrue(); + } + + [Fact] + public void ProducesPortableSuccessResponse_ShouldAccumulateRouteMetadata() + { + var attribute = GetMetadata( + static builder => + builder.ProducesPortableSuccessResponse( + StatusCodes.Status202Accepted, + configure: x => x.WithMetadata() + .WithMetadata(typeof(SuccessMetadata)) + .UseMetadataSerializationMode(MetadataSerializationMode.Always) + ), + static () => TypedResults.Ok(new MovieDto()) + ); + + attribute.StatusCode.Should().Be(StatusCodes.Status202Accepted); + attribute.ValueType.Should().Be(typeof(MovieDto)); + attribute.TopLevelMetadataType.Should().Be(typeof(SuccessMetadata)); + attribute.MetadataSerializationMode.Should().Be(MetadataSerializationMode.Always); + attribute.HasMetadataSerializationModeOverride.Should().BeTrue(); + } + + [Fact] + public async Task Transformer_ShouldKeepTypeAndSchemaContractEnvelopeShapeEquivalent() + { + await using var typeApp = CreateApp( + contracts => contracts.ForCode("Equivalent"), + endpoints => + { + endpoints + .MapGet("/equivalent", static () => Results.Problem()) + .WithName("EquivalentType") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes("Equivalent") + ); + } + ); + await using var schemaApp = CreateApp( + contracts => contracts.ForCode( + "Equivalent", + _ => new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["value"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + }, + Required = new HashSet(StringComparer.Ordinal) { "value" } + } + ), + endpoints => + { + endpoints + .MapGet("/equivalent", static () => Results.Problem()) + .WithName("EquivalentSchema") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes("Equivalent") + ); + } + ); + + var typeExtension = GetCodeSpecificExtension( + await GetOpenApiDocumentAsync(typeApp), + "PortableError__Equivalent" + ); + var schemaExtension = + GetCodeSpecificExtension(await GetOpenApiDocumentAsync(schemaApp), "PortableError__Equivalent"); + + typeExtension.Properties!.Keys.Should().BeEquivalentTo(schemaExtension.Properties!.Keys); + typeExtension.Required.Should().BeEquivalentTo(schemaExtension.Required); + typeExtension.Properties["metadata"].Should().BeOfType(); + schemaExtension.Properties["metadata"].Should().BeOfType(); + } + + private static TAttribute GetMetadata( + Action configure, + Delegate handler + ) + where TAttribute : class + { + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + configure(app.MapGet("/metadata", handler)); + + return ((IEndpointRouteBuilder) app).DataSources.SelectMany(static dataSource => dataSource.Endpoints) + .OfType() + .Single() + .Metadata.GetMetadata() ?? + throw new InvalidOperationException($"No metadata of type {typeof(TAttribute).FullName} was found."); + } + + private static WebApplication CreateApp( + Action configureContracts, + Action configureEndpoints + ) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMinimalApis(); + builder.Services.AddPortableResultsOpenApi(); + builder.Services.Configure( + options => + { + options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; + options.ValidationProblemSerializationFormat = + ValidationProblemSerializationFormat.AspNetCoreCompatible; + } + ); + builder.Services.ConfigureErrorMetadataContracts(configureContracts); + builder.Services.AddOpenApi(); + + var app = builder.Build(); + configureEndpoints(app); + return app; + } + + private static async Task GetOpenApiDocumentAsync(WebApplication app) + { + await app.StartAsync(TestContext.Current.CancellationToken); + var provider = app.Services.GetRequiredKeyedService("v1"); + return await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken); + } + + private static OpenApiSchema GetCodeSpecificExtension(OpenApiDocument document, string schemaId) + { + return (OpenApiSchema) GetSchemaComponent(document, schemaId).AllOf![1]; + } + + private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) + { + return (OpenApiSchema) document.Components!.Schemas![schemaId]; + } + + // ReSharper disable once ClassNeverInstantiated.Local -- required for testing + private sealed class EquivalentMetadata + { + // ReSharper disable once UnusedMember.Local -- required for testing + public int Value { get; init; } + } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs deleted file mode 100644 index 190765d..0000000 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ValidationOpenApiDocumentTransformerTests.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using FluentAssertions; -using Light.PortableResults.AspNetCore.MinimalApis; -using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; -using Light.PortableResults.AspNetCore.OpenApi.Schemas; -using Light.PortableResults.Http.Writing; -using Light.PortableResults.Validation; -using Light.PortableResults.Validation.Definitions; -using Light.PortableResults.Validation.OpenApi; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi; -using Xunit; - -namespace Light.PortableResults.AspNetCore.OpenApi.Tests; - -public sealed class ValidationOpenApiDocumentTransformerTests -{ - public static TheoryData TypedHelperCases => - new () - { - { "EqualTo", ValidationErrorCodes.EqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, - { "NotEqualTo", ValidationErrorCodes.NotEqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, - { "GreaterThan", ValidationErrorCodes.GreaterThan, [ValidationErrorMetadataKeys.ComparativeValue] }, - { - "GreaterThanOrEqualTo", - ValidationErrorCodes.GreaterThanOrEqualTo, - [ValidationErrorMetadataKeys.ComparativeValue] - }, - { "LessThan", ValidationErrorCodes.LessThan, [ValidationErrorMetadataKeys.ComparativeValue] }, - { - "LessThanOrEqualTo", - ValidationErrorCodes.LessThanOrEqualTo, - [ValidationErrorMetadataKeys.ComparativeValue] - }, - { - "InRange", - ValidationErrorCodes.InRange, - [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] - }, - { - "NotInRange", - ValidationErrorCodes.NotInRange, - [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] - }, - { - "ExclusiveRange", - ValidationErrorCodes.ExclusiveRange, - [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] - } - }; - - [Fact] - public async Task Transformer_ShouldEmitCodeOnlyExtensionForNoMetadataCodes() - { - await using var app = CreateApp( - contracts => contracts.RegisterBuiltInValidationErrors(), - endpoints => - { - endpoints - .MapGet("/no-metadata", static () => Results.Problem()) - .WithName("NoMetadata") - .ProducesPortableProblem( - StatusCodes.Status400BadRequest, - configure: x => x.WithErrorCodes(ValidationErrorCodes.NotNull) - ); - } - ); - - var document = await GetOpenApiDocumentAsync(app); - var component = GetSchemaComponent(document, "PortableError__NotNull"); - var extension = (OpenApiSchema) component.AllOf![1]; - - extension.Properties!.Keys.Should().BeEquivalentTo("code"); - extension.Properties.Should().NotContainKey("metadata"); - extension.Required.Should().BeEquivalentTo("code"); - } - - [Fact] - public async Task Transformer_ShouldEmitMetadataAndNoMetadataBuiltInCodesTogether() - { - await using var app = CreateApp( - contracts => contracts.RegisterBuiltInValidationErrors(), - endpoints => - { - endpoints - .MapGet("/count-and-not-null", static () => Results.Problem()) - .WithName("CountAndNotNull") - .ProducesPortableProblem( - StatusCodes.Status400BadRequest, - configure: x => x.WithErrorCodes(ValidationErrorCodes.Count, ValidationErrorCodes.NotNull) - ); - } - ); - - var document = await GetOpenApiDocumentAsync(app); - var responseItems = GetProblemItems(document, "/count-and-not-null"); - - responseItems.AnyOf!.Select(static schema => GetSchemaReferenceId((OpenApiSchemaReference) schema)) - .Should() - .Contain(["PortableError__Count", "PortableError__NotNull", "PortableError"]); - var countMetadata = GetSchemaComponent(document, "PortableError__Count__Metadata"); - countMetadata.Properties!.Keys.Should().BeEquivalentTo(ValidationErrorMetadataKeys.ExpectedCount); - ((OpenApiSchema) countMetadata.Properties[ValidationErrorMetadataKeys.ExpectedCount]).Type.Should() - .Be(JsonSchemaType.Integer); - } - - [Fact] - public async Task Transformer_ShouldProduceValidOpenApi30DocumentWithBuiltInCatalog() - { - await using var app = CreateApp( - contracts => contracts.RegisterBuiltInValidationErrors(), - endpoints => - { - endpoints - .MapGet("/catalog", static () => Results.Problem()) - .WithName("Catalog") - .ProducesPortableProblem( - StatusCodes.Status400BadRequest, - configure: x => x.WithErrorCodes(BuiltInValidationErrorContracts.Contracts.Keys.ToArray()) - ); - }, - options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 - ); - - var document = await GetOpenApiDocumentAsync(app); - var validationErrors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); - await using var stream = new MemoryStream(); - await document.SerializeAsJsonAsync(stream, OpenApiSpecVersion.OpenApi3_0, TestContext.Current.CancellationToken); - var json = Encoding.UTF8.GetString(stream.ToArray()); - - validationErrors.Should().BeEmpty(); - json.Should().NotContain("\"type\":\"null\""); - } - - [Fact] - public async Task Transformer_ShouldKeepTypeAndSchemaContractEnvelopeShapeEquivalent() - { - await using var typeApp = CreateApp( - contracts => contracts.ForCode("Equivalent"), - endpoints => - { - endpoints - .MapGet("/equivalent", static () => Results.Problem()) - .WithName("EquivalentType") - .ProducesPortableProblem( - StatusCodes.Status400BadRequest, - configure: x => x.WithErrorCodes("Equivalent") - ); - } - ); - await using var schemaApp = CreateApp( - contracts => contracts.ForCode( - "Equivalent", - _ => new OpenApiSchema - { - Type = JsonSchemaType.Object, - Properties = new Dictionary(StringComparer.Ordinal) - { - ["value"] = new OpenApiSchema { Type = JsonSchemaType.Integer } - }, - Required = new HashSet(StringComparer.Ordinal) { "value" } - } - ), - endpoints => - { - endpoints - .MapGet("/equivalent", static () => Results.Problem()) - .WithName("EquivalentSchema") - .ProducesPortableProblem( - StatusCodes.Status400BadRequest, - configure: x => x.WithErrorCodes("Equivalent") - ); - } - ); - - var typeExtension = GetCodeSpecificExtension(await GetOpenApiDocumentAsync(typeApp), "PortableError__Equivalent"); - var schemaExtension = - GetCodeSpecificExtension(await GetOpenApiDocumentAsync(schemaApp), "PortableError__Equivalent"); - - typeExtension.Properties!.Keys.Should().BeEquivalentTo(schemaExtension.Properties!.Keys); - typeExtension.Required.Should().BeEquivalentTo(schemaExtension.Required); - typeExtension.Properties["metadata"].Should().BeOfType(); - schemaExtension.Properties["metadata"].Should().BeOfType(); - } - - [Theory] - [MemberData(nameof(TypedHelperCases))] - public async Task TypedHelpers_ShouldEmitEndpointScopedIntegerMetadata( - string operationName, - string code, - string[] properties - ) - { - await using var app = CreateTypedHelperApp(operationName); - - var document = await GetOpenApiDocumentAsync(app); - var metadata = GetSchemaComponent( - document, - $"PortableError__{operationName}__400__application_problem_json__{code}__Metadata" - ); - - metadata.Properties!.Keys.Should().BeEquivalentTo(properties); - metadata.Required.Should().BeEquivalentTo(properties); - properties.Should().OnlyContain( - property => SchemaIncludesType((OpenApiSchema) metadata.Properties[property], JsonSchemaType.Integer) - ); - } - - [Fact] - public async Task TypedHelpers_ShouldEmitEndpointScopedDateTimeMetadata() - { - await using var app = CreateTypedHelperApp("InRangeDateTime"); - - var document = await GetOpenApiDocumentAsync(app); - var metadata = GetSchemaComponent( - document, - "PortableError__InRangeDateTime__400__application_problem_json__InRange__Metadata" - ); - - foreach (var property in new[] { ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary }) - { - var schema = (OpenApiSchema) metadata.Properties![property]; - SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); - schema.Format.Should().Be("date-time"); - } - } - - [Fact] - public async Task Transformer_ShouldMixGlobalAndEndpointScopedBuiltInContracts() - { - await using var app = CreateApp( - contracts => contracts.RegisterBuiltInValidationErrors(), - endpoints => - { - endpoints - .MapGet("/mixed", static () => Results.Problem()) - .WithName("Mixed") - .ProducesPortableValidationProblem( - configure: x => x - .UseFormat(ValidationProblemSerializationFormat.Rich) - .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) - .WithInRangeError() - ); - } - ); - - var document = await GetOpenApiDocumentAsync(app); - var responseItems = GetProblemItems(document, "/mixed"); - - responseItems.AnyOf!.Select(static schema => GetSchemaReferenceId((OpenApiSchemaReference) schema)) - .Should() - .Contain( - [ - "PortableError__NotEmpty", - "PortableError__LengthInRange", - "PortableError__Mixed__400__application_problem_json__InRange", - "PortableError" - ] - ); - } - - private static WebApplication CreateTypedHelperApp(string operationName) - { - return CreateApp( - _ => { }, - endpoints => - { - endpoints - .MapGet("/" + operationName.ToLowerInvariant(), static () => Results.Problem()) - .WithName(operationName) - .ProducesPortableValidationProblem( - configure: builder => - { - builder.UseFormat(ValidationProblemSerializationFormat.Rich); - AddTypedHelper(operationName, builder); - } - ); - } - ); - } - - private static void AddTypedHelper(string operationName, PortableValidationProblemOpenApiBuilder builder) - { - switch (operationName) - { - case "EqualTo": - builder.WithEqualToError(); - break; - case "NotEqualTo": - builder.WithNotEqualToError(); - break; - case "GreaterThan": - builder.WithGreaterThanError(); - break; - case "GreaterThanOrEqualTo": - builder.WithGreaterThanOrEqualToError(); - break; - case "LessThan": - builder.WithLessThanError(); - break; - case "LessThanOrEqualTo": - builder.WithLessThanOrEqualToError(); - break; - case "InRange": - case "InRangeDateTime": - builder.WithInRangeError(); - break; - case "NotInRange": - builder.WithNotInRangeError(); - break; - case "ExclusiveRange": - builder.WithExclusiveRangeError(); - break; - default: - throw new InvalidOperationException("Unknown helper: " + operationName); - } - } - - private static WebApplication CreateApp( - Action configureContracts, - Action configureEndpoints, - Action? configureOpenApi = null - ) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddPortableResultsForMinimalApis(); - builder.Services.AddPortableResultsOpenApi(); - builder.Services.Configure( - options => options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich - ); - builder.Services.ConfigureErrorMetadataContracts(configureContracts); - builder.Services.AddOpenApi(options => configureOpenApi?.Invoke(options)); - - var app = builder.Build(); - configureEndpoints(app); - return app; - } - - private static async Task GetOpenApiDocumentAsync(WebApplication app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - var provider = app.Services.GetRequiredKeyedService("v1"); - return await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken); - } - - private static OpenApiSchema GetProblemItems(OpenApiDocument document, string path) - { - var response = (OpenApiResponse) document.Paths[path] - .Operations![HttpMethod.Get] - .Responses![StatusCodes.Status400BadRequest.ToString(CultureInfo.InvariantCulture)]; - var schema = (OpenApiSchemaReference) response.Content!["application/problem+json"].Schema!; - var component = GetSchemaComponent(document, GetSchemaReferenceId(schema)); - var extension = (OpenApiSchema) component.AllOf![1]; - var propertyName = extension.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; - return (OpenApiSchema) ((OpenApiSchema) extension.Properties[propertyName]).Items!; - } - - private static OpenApiSchema GetCodeSpecificExtension(OpenApiDocument document, string schemaId) - { - return (OpenApiSchema) GetSchemaComponent(document, schemaId).AllOf![1]; - } - - private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) - { - return (OpenApiSchema) document.Components!.Schemas![schemaId]; - } - - private static string GetSchemaReferenceId(OpenApiSchemaReference schemaReference) - { - var referenceId = schemaReference.Reference.Id ?? schemaReference.Id; - referenceId.Should().NotBeNull(); - return referenceId; - } - - private static bool SchemaIncludesType(OpenApiSchema schema, JsonSchemaType type) - { - return schema.Type.HasValue && (schema.Type.Value & type) == type; - } - - private sealed class EquivalentMetadata - { - public int Value { get; init; } - } -} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs similarity index 86% rename from tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs rename to tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index 5924d3a..03aeadf 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -1,14 +1,13 @@ +using System; using System.Collections.Frozen; using System.Linq; using FluentAssertions; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; -using Light.PortableResults.Validation; using Light.PortableResults.Validation.Definitions; -using Light.PortableResults.Validation.OpenApi; using Microsoft.OpenApi; using Xunit; -namespace Light.PortableResults.AspNetCore.OpenApi.Tests; +namespace Light.PortableResults.Validation.OpenApi.Tests; public sealed class BuiltInValidationErrorContractsTests { @@ -115,9 +114,10 @@ public void Contracts_ShouldContainExpectedBuiltInCodes() BuiltInValidationErrorContracts.Contracts.Keys.Should() .BeEquivalentTo(MetadataBearingCodes.Concat(expectedNoMetadataCodes)); BuiltInValidationErrorContracts.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); - BuiltInValidationErrorContracts.Contracts.Values.Should().AllSatisfy( - x => ReferenceEquals(x, PortableErrorMetadataContract.NoMetadata) - ); + foreach (var code in expectedNoMetadataCodes) + { + BuiltInValidationErrorContracts.Contracts[code].Should().BeSameAs(PortableErrorMetadataContract.NoMetadata); + } } [Fact] @@ -196,6 +196,29 @@ public void RegisterBuiltInValidationErrors_ShouldRegisterExpectedCodes() registry.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); } + [Fact] + public void RegisterBuiltInValidationErrors_ShouldBeIdempotent() + { + var builder = new PortableErrorMetadataContractsBuilder(); + + builder.RegisterBuiltInValidationErrors(); + builder.RegisterBuiltInValidationErrors(); + var registry = new PortableErrorMetadataContractRegistry(builder); + + registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); + } + + [Fact] + public void RegisterBuiltInValidationErrors_ShouldRejectConflictingPreRegisteredContracts() + { + var builder = new PortableErrorMetadataContractsBuilder() + .ForCode(ValidationErrorCodes.Count); + + var act = builder.RegisterBuiltInValidationErrors; + + act.Should().Throw().WithMessage("*Count*ConflictingMetadata*"); + } + private static OpenApiSchema GetFirstPrimitiveValueSchema(string code, OpenApiSpecVersion version) { var contract = (PortableErrorMetadataSchemaContract) BuiltInValidationErrorContracts.Contracts[code]; @@ -205,4 +228,10 @@ private static OpenApiSchema GetFirstPrimitiveValueSchema(string code, OpenApiSp ValidationErrorMetadataKeys.LowerBoundary; return (OpenApiSchema) schema.Properties[propertyName]; } + + // ReSharper disable once ClassNeverInstantiated.Local -- required for testing + private sealed class ConflictingMetadata + { + public int Value { get; init; } + } } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs new file mode 100644 index 0000000..608bf8c --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using Light.PortableResults.Validation.Definitions; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +public sealed class BuiltInValidationErrorMetadataTests +{ + private static readonly JsonSerializerOptions SerializerOptions = new () + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static TheoryData MetadataCases => + new () + { + { new EqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, + { new NotEqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, + { new GreaterThanMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, + { new GreaterThanOrEqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, + { new LessThanMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, + { new LessThanOrEqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, + { + new InRangeMetadata( + new DateTime(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc), + new DateTime(2024, 2, 4, 4, 5, 6, DateTimeKind.Utc) + ), + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + new NotInRangeMetadata(1, 10), + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + new ExclusiveRangeMetadata(1, 10), + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + } + }; + + [Theory] + [MemberData(nameof(MetadataCases))] + public void MetadataRecords_ShouldSerializeExpectedProperties(object metadata, string[] expectedProperties) + { + var payload = JsonNode.Parse(JsonSerializer.Serialize(metadata, SerializerOptions)) + .Should() + .BeOfType() + .Subject; + + payload.Select(static pair => pair.Key).Should().BeEquivalentTo(expectedProperties); + foreach (var property in expectedProperties) + { + payload[property].Should().NotBeNull(); + } + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/Light.PortableResults.Validation.OpenApi.Tests.csproj b/tests/Light.PortableResults.Validation.OpenApi.Tests/Light.PortableResults.Validation.OpenApi.Tests.csproj new file mode 100644 index 0000000..a9adbc6 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/Light.PortableResults.Validation.OpenApi.Tests.csproj @@ -0,0 +1,32 @@ + + + + Exe + false + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs new file mode 100644 index 0000000..de261e2 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation.Definitions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +public sealed class PortableProblemOpenApiBuilderTests +{ + [Theory] + [MemberData( + nameof(ValidationTypedHelperTestData.TypedHelperCases), + MemberType = typeof(ValidationTypedHelperTestData) + )] + public async Task TypedHelpers_ShouldEmitEndpointScopedIntegerMetadata( + string operationName, + string code, + string[] properties + ) + { + await using var app = CreateTypedHelperApp(operationName); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var metadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + $"PortableError__{operationName}__400__application_problem_json__{code}__Metadata" + ); + + metadata.Properties!.Keys.Should().BeEquivalentTo(properties); + metadata.Required.Should().BeEquivalentTo(properties); + properties.Should().OnlyContain( + property => + ValidationOpenApiDocumentTestUtilities.SchemaIncludesType( + (OpenApiSchema) metadata.Properties[property], + JsonSchemaType.Integer + ) + ); + } + + [Fact] + public async Task TypedHelpers_ShouldEmitEndpointScopedDateTimeMetadata() + { + await using var app = CreateTypedHelperApp("InRangeDateTimeProblem"); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var metadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + "PortableError__InRangeDateTimeProblem__400__application_problem_json__InRange__Metadata" + ); + + foreach (var property in new[] + { ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary }) + { + var schema = (OpenApiSchema) metadata.Properties![property]; + ValidationOpenApiDocumentTestUtilities.SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); + schema.Format.Should().Be("date-time"); + } + } + + [Fact] + public async Task ProducesPortableProblem_ShouldMixGlobalAndEndpointScopedBuiltInContracts() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/mixed-problem", static () => Results.Problem()) + .WithName("MixedProblem") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) + .WithInRangeError() + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var responseItems = ValidationOpenApiDocumentTestUtilities.GetProblemItems(document, "/mixed-problem"); + + responseItems.AnyOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .Contain( + [ + "PortableError__NotEmpty", + "PortableError__LengthInRange", + "PortableError__MixedProblem__400__application_problem_json__InRange", + "PortableError" + ] + ); + } + + private static WebApplication CreateTypedHelperApp(string operationName) + { + return ValidationOpenApiDocumentTestUtilities.CreateApp( + _ => { }, + endpoints => + { + endpoints + .MapGet("/" + operationName.ToLowerInvariant(), static () => Results.Problem()) + .WithName(operationName) + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: builder => ValidationTypedHelperTestData.AddTypedHelper(operationName, builder) + ); + } + ); + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs new file mode 100644 index 0000000..99604b3 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs @@ -0,0 +1,218 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.Validation.Definitions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +public sealed class PortableValidationProblemOpenApiBuilderTests +{ + [Fact] + public async Task Transformer_ShouldEmitCodeOnlyExtensionForNoMetadataCodes() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/no-metadata", static () => Results.Problem()) + .WithName("NoMetadata") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes(ValidationErrorCodes.NotNull) + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var component = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent(document, "PortableError__NotNull"); + var extension = (OpenApiSchema) component.AllOf![1]; + + extension.Properties!.Keys.Should().BeEquivalentTo("code"); + extension.Properties.Should().NotContainKey("metadata"); + extension.Required.Should().BeEquivalentTo("code"); + } + + [Fact] + public async Task Transformer_ShouldEmitMetadataAndNoMetadataBuiltInCodesTogether() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/count-and-not-null", static () => Results.Problem()) + .WithName("CountAndNotNull") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes(ValidationErrorCodes.Count, ValidationErrorCodes.NotNull) + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var responseItems = ValidationOpenApiDocumentTestUtilities.GetProblemItems(document, "/count-and-not-null"); + + responseItems.AnyOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .Contain(["PortableError__Count", "PortableError__NotNull", "PortableError"]); + var countMetadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + "PortableError__Count__Metadata" + ); + countMetadata.Properties!.Keys.Should().BeEquivalentTo(ValidationErrorMetadataKeys.ExpectedCount); + ((OpenApiSchema) countMetadata.Properties[ValidationErrorMetadataKeys.ExpectedCount]).Type.Should() + .Be(JsonSchemaType.Integer); + } + + [Fact] + public async Task Transformer_ShouldProduceValidOpenApi30DocumentWithBuiltInCatalog() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/catalog", static () => Results.Problem()) + .WithName("Catalog") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: x => x.WithErrorCodes(BuiltInValidationErrorContracts.Contracts.Keys.ToArray()) + ); + }, + options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var validationErrors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + await using var stream = new MemoryStream(); + await document.SerializeAsJsonAsync( + stream, + OpenApiSpecVersion.OpenApi3_0, + TestContext.Current.CancellationToken + ); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + validationErrors.Should().BeEmpty(); + json.Should().NotContain("\"type\":\"null\""); + } + + [Theory] + [MemberData( + nameof(ValidationTypedHelperTestData.TypedHelperCases), + MemberType = typeof(ValidationTypedHelperTestData) + )] + public async Task TypedHelpers_ShouldEmitEndpointScopedIntegerMetadata( + string operationName, + string code, + string[] properties + ) + { + await using var app = CreateTypedHelperApp(operationName); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var metadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + $"PortableError__{operationName}__400__application_problem_json__{code}__Metadata" + ); + + metadata.Properties!.Keys.Should().BeEquivalentTo(properties); + metadata.Required.Should().BeEquivalentTo(properties); + properties.Should().OnlyContain( + property => + ValidationOpenApiDocumentTestUtilities.SchemaIncludesType( + (OpenApiSchema) metadata.Properties[property], + JsonSchemaType.Integer + ) + ); + } + + [Fact] + public async Task TypedHelpers_ShouldEmitEndpointScopedDateTimeMetadata() + { + await using var app = CreateTypedHelperApp("InRangeDateTimeValidation"); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var metadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + "PortableError__InRangeDateTimeValidation__400__application_problem_json__InRange__Metadata" + ); + + foreach (var property in new[] + { ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary }) + { + var schema = (OpenApiSchema) metadata.Properties![property]; + ValidationOpenApiDocumentTestUtilities.SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); + schema.Format.Should().Be("date-time"); + } + } + + [Fact] + public async Task ProducesPortableValidationProblem_ShouldMixGlobalAndEndpointScopedBuiltInContracts() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapGet("/mixed-validation", static () => Results.Problem()) + .WithName("MixedValidation") + .ProducesPortableValidationProblem( + configure: x => x + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithInRangeError() + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var responseItems = ValidationOpenApiDocumentTestUtilities.GetProblemItems(document, "/mixed-validation"); + + responseItems.AnyOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .Contain( + [ + "PortableError__NotEmpty", + "PortableError__LengthInRange", + "PortableError__MixedValidation__400__application_problem_json__InRange", + "PortableError" + ] + ); + } + + private static WebApplication CreateTypedHelperApp(string operationName) + { + return ValidationOpenApiDocumentTestUtilities.CreateApp( + _ => { }, + endpoints => + { + endpoints + .MapGet("/" + operationName.ToLowerInvariant(), static () => Results.Problem()) + .WithName(operationName) + .ProducesPortableValidationProblem( + configure: builder => + { + builder.UseFormat(ValidationProblemSerializationFormat.Rich); + ValidationTypedHelperTestData.AddTypedHelper(operationName, builder); + } + ); + } + ); + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs new file mode 100644 index 0000000..d934189 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +internal static class ValidationOpenApiDocumentTestUtilities +{ + internal static WebApplication CreateApp( + Action configureContracts, + Action configureEndpoints, + Action? configureOpenApi = null + ) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMinimalApis(); + builder.Services.AddPortableResultsOpenApi(); + builder.Services.Configure( + options => options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich + ); + builder.Services.ConfigureErrorMetadataContracts(configureContracts); + builder.Services.AddOpenApi(options => configureOpenApi?.Invoke(options)); + + var app = builder.Build(); + configureEndpoints(app); + return app; + } + + internal static async Task GetOpenApiDocumentAsync(WebApplication app) + { + await app.StartAsync(TestContext.Current.CancellationToken); + var provider = app.Services.GetRequiredKeyedService("v1"); + return await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken); + } + + internal static OpenApiSchema GetProblemItems(OpenApiDocument document, string path) + { + var response = (OpenApiResponse) document.Paths[path] + .Operations![HttpMethod.Get] + .Responses![StatusCodes.Status400BadRequest.ToString(CultureInfo.InvariantCulture)]; + var schema = (OpenApiSchemaReference) response.Content!["application/problem+json"].Schema!; + var component = GetSchemaComponent(document, GetSchemaReferenceId(schema)); + var extension = (OpenApiSchema) component.AllOf![1]; + var propertyName = extension.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; + return (OpenApiSchema) ((OpenApiSchema) extension.Properties[propertyName]).Items!; + } + + internal static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) + { + return (OpenApiSchema) document.Components!.Schemas![schemaId]; + } + + internal static string GetSchemaReferenceId(OpenApiSchemaReference schemaReference) + { + var referenceId = schemaReference.Reference.Id ?? schemaReference.Id; + referenceId.Should().NotBeNull(); + return referenceId; + } + + internal static bool SchemaIncludesType(OpenApiSchema schema, JsonSchemaType type) + { + return schema.Type.HasValue && (schema.Type.Value & type) == type; + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationTypedHelperTestData.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationTypedHelperTestData.cs new file mode 100644 index 0000000..3e9d609 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationTypedHelperTestData.cs @@ -0,0 +1,117 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation.Definitions; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +internal static class ValidationTypedHelperTestData +{ + public static TheoryData TypedHelperCases => + new () + { + { "EqualTo", ValidationErrorCodes.EqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { "NotEqualTo", ValidationErrorCodes.NotEqualTo, [ValidationErrorMetadataKeys.ComparativeValue] }, + { "GreaterThan", ValidationErrorCodes.GreaterThan, [ValidationErrorMetadataKeys.ComparativeValue] }, + { + "GreaterThanOrEqualTo", + ValidationErrorCodes.GreaterThanOrEqualTo, + [ValidationErrorMetadataKeys.ComparativeValue] + }, + { "LessThan", ValidationErrorCodes.LessThan, [ValidationErrorMetadataKeys.ComparativeValue] }, + { + "LessThanOrEqualTo", + ValidationErrorCodes.LessThanOrEqualTo, + [ValidationErrorMetadataKeys.ComparativeValue] + }, + { + "InRange", + ValidationErrorCodes.InRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + "NotInRange", + ValidationErrorCodes.NotInRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + }, + { + "ExclusiveRange", + ValidationErrorCodes.ExclusiveRange, + [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] + } + }; + + internal static void AddTypedHelper(string operationName, PortableProblemOpenApiBuilder builder) + { + switch (operationName) + { + case "EqualTo": + builder.WithEqualToError(); + break; + case "NotEqualTo": + builder.WithNotEqualToError(); + break; + case "GreaterThan": + builder.WithGreaterThanError(); + break; + case "GreaterThanOrEqualTo": + builder.WithGreaterThanOrEqualToError(); + break; + case "LessThan": + builder.WithLessThanError(); + break; + case "LessThanOrEqualTo": + builder.WithLessThanOrEqualToError(); + break; + case "InRange": + case "InRangeDateTimeProblem": + builder.WithInRangeError(); + break; + case "NotInRange": + builder.WithNotInRangeError(); + break; + case "ExclusiveRange": + builder.WithExclusiveRangeError(); + break; + default: + throw new InvalidOperationException("Unknown helper: " + operationName); + } + } + + internal static void AddTypedHelper(string operationName, PortableValidationProblemOpenApiBuilder builder) + { + switch (operationName) + { + case "EqualTo": + builder.WithEqualToError(); + break; + case "NotEqualTo": + builder.WithNotEqualToError(); + break; + case "GreaterThan": + builder.WithGreaterThanError(); + break; + case "GreaterThanOrEqualTo": + builder.WithGreaterThanOrEqualToError(); + break; + case "LessThan": + builder.WithLessThanError(); + break; + case "LessThanOrEqualTo": + builder.WithLessThanOrEqualToError(); + break; + case "InRange": + case "InRangeDateTimeValidation": + builder.WithInRangeError(); + break; + case "NotInRange": + builder.WithNotInRangeError(); + break; + case "ExclusiveRange": + builder.WithExclusiveRangeError(); + break; + default: + throw new InvalidOperationException("Unknown helper: " + operationName); + } + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/packages.lock.json b/tests/Light.PortableResults.Validation.OpenApi.Tests/packages.lock.json new file mode 100644 index 0000000..8a5d0b6 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/packages.lock.json @@ -0,0 +1,312 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "heVQl5tKYnnIDYlR1QMVGueYH6iriZTcZB6AjDczQNwZzxkjDIt9C84Pt4cCiZYrbo7jkZOYGWbs6Lo9wAtVLg==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[7.2.2, 7.2.2]", + "resolved": "7.2.2", + "contentHash": "B6FJmtTadaqtLXH5qfn9hlYF5ruNOAdtjA9+1V3fuYp/MZzj7lB3Ys5cdgy72uv7w1GE5Y9PEX369UD3N1sfHg==", + "dependencies": { + "System.Configuration.ConfigurationManager": "6.0.0" + } + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MfacYQ7jNzj6073YobyoFfXpNmGqrV1UCywTM339DOcYpfalcM4K4heFjV5k3dDkKkWOGWO/DV3hdmVRqFkIxA==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.4.0, )", + "resolved": "18.4.0", + "contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.4.0", + "Microsoft.TestPlatform.TestHost": "18.4.0" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "PJEdrZnnhvxIEXzDdvdZ38GvpdaiUfKkZ99kudS8riJwhowFb/Qh26Wjk9smrCWcYdMFQmpN5epGiL4o1s8LYA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.4.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "7T+m0kDSlIPTHIkPMIu6m6tV6qsMqJpvQWW2jIc2qi7sn40qxFo0q+7mEQAhMPXZHMKnWrnv47ntGlM/ejvw3g==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "6.0.0", + "System.Security.Permissions": "6.0.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "rp1gMNEZpvx9vP0JW0oHLxlf8oSiQgtno77Y4PLUBjSiDYoD77Y8uXHr1Ea5XG4/pIKhqAdxZ8v8OTUtqo9PeQ==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "T/uuc7AklkDoxmcJ7LGkyX1CcSviZuLCa4jg3PekfJ7SU0niF0IVTXwUiNVP9DSpzou2PpxJ+eNY2IfDM90ZCg==", + "dependencies": { + "System.Windows.Extensions": "6.0.0" + } + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "IXoJOXIqc39AIe+CIR7koBtRGMiCt/LPM3lI+PELtDIy9XdyeSrwXFdWV9dzJ2Awl0paLWUaknLxFQ5HpHZUog==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "light.portableresults": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.HashCode": "[6.0.0, )", + "Ulid": "[1.4.1, )" + } + }, + "light.portableresults.aspnetcore.minimalapis": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" + } + }, + "light.portableresults.aspnetcore.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "light.portableresults.aspnetcore.shared": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "light.portableresults.validation": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.4.0, )" + } + }, + "light.portableresults.validation.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.OpenApi": "[0.4.0, )", + "Light.PortableResults.Validation": "[0.4.0, )" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "hQB3Hq1LlF0NkGVNyZIvwIQIY3LM7Cw1oYjNiTvdNqmzzipVAWEK1c5sj2H5aFX0udnjgPLxSYKq2fupueS8ow==" + }, + "Microsoft.Bcl.HashCode": { + "type": "CentralTransitive", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Ulid": { + "type": "CentralTransitive", + "requested": "[1.4.1, )", + "resolved": "1.4.1", + "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" + } + } + } +} \ No newline at end of file diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/xunit.runner.json b/tests/Light.PortableResults.Validation.OpenApi.Tests/xunit.runner.json new file mode 100644 index 0000000..c2f8426 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} From 9f866cab36c21bf2c83a425173492e1cc7af8cad Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 22:16:56 +0200 Subject: [PATCH 45/67] chore: simplify name to PortableNoMetadataContract Signed-off-by: Kenny Pflug --- .../ErrorContracts/PortableErrorMetadataContract.cs | 8 +++----- .../PortableErrorMetadataContractEqualityComparer.cs | 4 ++-- .../PortableResultsOpenApiDocumentTransformer.cs | 2 +- .../Generation/PortableResultsOpenApiMessages.cs | 2 +- ...uiltInValidationErrorContractRegistrationExtensions.cs | 2 +- .../PortableErrorMetadataContractTests.cs | 4 ++-- 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs index bdc61d8..9c8d2bb 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs @@ -9,14 +9,12 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// public abstract class PortableErrorMetadataContract { - private static readonly PortableErrorMetadataNoMetadataContract SharedNoMetadata = new (); - private protected PortableErrorMetadataContract() { } /// /// Gets the singleton contract for error codes that do not emit metadata. /// - public static PortableErrorMetadataContract NoMetadata => SharedNoMetadata; + public static PortableErrorMetadataContract NoMetadata { get; } = new PortableNoMetadataContract(); /// /// Creates a contract backed by a CLR metadata type. @@ -137,7 +135,7 @@ private static string CreateDiagnosticName( /// /// Represents a metadata contract for error codes that do not emit metadata. /// -public sealed class PortableErrorMetadataNoMetadataContract : PortableErrorMetadataContract +public sealed class PortableNoMetadataContract : PortableErrorMetadataContract { - internal PortableErrorMetadataNoMetadataContract() { } + internal PortableNoMetadataContract() { } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs index 75f30ad..45c4a7f 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs @@ -26,7 +26,7 @@ public bool Equals(PortableErrorMetadataContract? x, PortableErrorMetadataContra xType.MetadataType == yType.MetadataType, PortableErrorMetadataSchemaContract xSchema when y is PortableErrorMetadataSchemaContract ySchema => ReferenceEquals(xSchema.SchemaFactory, ySchema.SchemaFactory), - PortableErrorMetadataNoMetadataContract when y is PortableErrorMetadataNoMetadataContract => true, + PortableNoMetadataContract when y is PortableNoMetadataContract => true, _ => false }; } @@ -37,7 +37,7 @@ public int GetHashCode(PortableErrorMetadataContract obj) { PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.GetHashCode(), PortableErrorMetadataSchemaContract schemaContract => schemaContract.SchemaFactory.GetHashCode(), - PortableErrorMetadataNoMetadataContract => 0, + PortableNoMetadataContract => 0, _ => obj.GetHashCode() }; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index ec8be4f..ccb76c2 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -609,7 +609,7 @@ CancellationToken cancellationToken metadataSchemaId, schemaContract.SchemaFactory(openApiVersion) ), - PortableErrorMetadataNoMetadataContract => null, + PortableNoMetadataContract => null, _ => throw new InvalidOperationException( $"The error metadata contract '{contract.GetType().FullName}' is not supported." ) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index 197aee6..3386f6c 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -31,7 +31,7 @@ private static string DescribeContract(PortableErrorMetadataContract contract) PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.FullName ?? typeContract.MetadataType.Name, PortableErrorMetadataSchemaContract schemaContract => DescribeSchemaFactory(schemaContract), - PortableErrorMetadataNoMetadataContract => "no metadata", + PortableNoMetadataContract => "no metadata", _ => contract.GetType().FullName ?? contract.GetType().Name }; } diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs index d69d90b..f2d8790 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs @@ -29,7 +29,7 @@ this PortableErrorMetadataContractsBuilder builder case PortableErrorMetadataSchemaContract schemaContract: builder.ForCode(code, schemaContract.SchemaFactory); break; - case PortableErrorMetadataNoMetadataContract: + case PortableNoMetadataContract: builder.ForCode(code); break; default: diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs index f0cffe7..bdb3bd6 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -24,7 +24,7 @@ public void ContractFactories_ShouldExposeClosedSubclassPayloads() schemaContract.Should().BeOfType() .Which.SchemaFactory.Should().BeSameAs(schemaFactory); ((PortableErrorMetadataSchemaContract) schemaContract).DiagnosticName.Should().Be(nameof(schemaFactory)); - noMetadata.Should().BeOfType(); + noMetadata.Should().BeOfType(); PortableErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); typeof(PortableErrorMetadataContract) .GetMember("Kind") @@ -35,7 +35,7 @@ public void ContractFactories_ShouldExposeClosedSubclassPayloads() { PortableErrorMetadataTypeContract => "type", PortableErrorMetadataSchemaContract => "schema", - PortableErrorMetadataNoMetadataContract => "none", + PortableNoMetadataContract => "none", _ => "unknown" }; discriminator.Should().Be("type"); From fa8a1d965554ce8c3e229b6875eee61205b64eac Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 22:57:46 +0200 Subject: [PATCH 46/67] chore: remove PortableErrorMetadataContractEqualityComparer and implement equality in PortableErrorMetadataContract class hierarchy Signed-off-by: Kenny Pflug --- .../PortableErrorMetadataContract.cs | 20 +++++++++ ...leErrorMetadataContractEqualityComparer.cs | 44 ------------------- .../PortableErrorMetadataContractRegistry.cs | 2 +- .../PortableErrorMetadataContractsBuilder.cs | 2 +- ...rtableResultsOpenApiDocumentTransformer.cs | 4 +- 5 files changed, 24 insertions(+), 48 deletions(-) delete mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs index 9c8d2bb..355db67 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs @@ -62,6 +62,13 @@ public PortableErrorMetadataTypeContract(Type metadataType) /// Gets the CLR metadata type. /// public Type MetadataType { get; } + + /// + public override bool Equals(object? obj) => + obj is PortableErrorMetadataTypeContract other && MetadataType == other.MetadataType; + + /// + public override int GetHashCode() => MetadataType.GetHashCode(); } /// @@ -94,6 +101,13 @@ public PortableErrorMetadataSchemaContract( /// public string DiagnosticName { get; } + /// + public override bool Equals(object? obj) => + obj is PortableErrorMetadataSchemaContract other && ReferenceEquals(SchemaFactory, other.SchemaFactory); + + /// + public override int GetHashCode() => SchemaFactory.GetHashCode(); + private static string CreateDiagnosticName( Func schemaFactory, string? diagnosticName @@ -138,4 +152,10 @@ private static string CreateDiagnosticName( public sealed class PortableNoMetadataContract : PortableErrorMetadataContract { internal PortableNoMetadataContract() { } + + /// + public override bool Equals(object? obj) => obj is PortableNoMetadataContract; + + /// + public override int GetHashCode() => 0; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs deleted file mode 100644 index 45c4a7f..0000000 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractEqualityComparer.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; - -namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; - -internal sealed class PortableErrorMetadataContractEqualityComparer : IEqualityComparer -{ - internal static PortableErrorMetadataContractEqualityComparer Instance { get; } = new (); - - private PortableErrorMetadataContractEqualityComparer() { } - - public bool Equals(PortableErrorMetadataContract? x, PortableErrorMetadataContract? y) - { - if (ReferenceEquals(x, y)) - { - return true; - } - - if (x is null || y is null) - { - return false; - } - - return x switch - { - PortableErrorMetadataTypeContract xType when y is PortableErrorMetadataTypeContract yType => - xType.MetadataType == yType.MetadataType, - PortableErrorMetadataSchemaContract xSchema when y is PortableErrorMetadataSchemaContract ySchema => - ReferenceEquals(xSchema.SchemaFactory, ySchema.SchemaFactory), - PortableNoMetadataContract when y is PortableNoMetadataContract => true, - _ => false - }; - } - - public int GetHashCode(PortableErrorMetadataContract obj) - { - return obj switch - { - PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.GetHashCode(), - PortableErrorMetadataSchemaContract schemaContract => schemaContract.SchemaFactory.GetHashCode(), - PortableNoMetadataContract => 0, - _ => obj.GetHashCode() - }; - } -} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs index 0356252..2c1ed5f 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs @@ -25,7 +25,7 @@ public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuild { if (contracts.TryGetValue(code, out var existingContract)) { - if (PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, contract)) + if (existingContract.Equals(contract)) { continue; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs index 494949d..9eca1b6 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs @@ -63,7 +63,7 @@ internal PortableErrorMetadataContractsBuilder ForCode(string code, PortableErro if (_contracts.TryGetValue(code, out var existingContract)) { - if (PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, contract)) + if (existingContract.Equals(contract)) { return this; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index ccb76c2..022d947 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -439,7 +439,7 @@ CancellationToken cancellationToken var metadataTypeContract = PortableErrorMetadataContract.FromType(metadataType); if (rawCodeContracts.TryGetValue(code, out var existingContract)) { - if (!PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, metadataTypeContract)) + if (!existingContract.Equals(metadataTypeContract)) { throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( @@ -814,7 +814,7 @@ PortableErrorMetadataContract contract { if (rawCodeContracts.TryGetValue(code, out var existingContract)) { - if (PortableErrorMetadataContractEqualityComparer.Instance.Equals(existingContract, contract)) + if (existingContract.Equals(contract)) { return; } From 3b274272aac37a6bf409d1677402a80116a58c0e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Apr 2026 23:11:04 +0200 Subject: [PATCH 47/67] test: improve coverage of OpenAPI transformer Signed-off-by: Kenny Pflug --- ...eResultsOpenApiDocumentTransformerTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index f222d40..0da069f 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -468,6 +468,113 @@ public void ErrorMetadataContractsBuilder_ShouldRejectSanitizedCodeCollisions() act.Should().Throw().WithMessage("*Code/One*Code_One*"); } + [Fact] + public async Task Transformer_ShouldOmitMetadataProperty_WhenGlobalCodeHasNoMetadata() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMinimalApis(); + builder.Services.AddPortableResultsOpenApi(); + builder.Services.ConfigureErrorMetadataContracts(contracts => contracts.ForCode("SimpleError")); + builder.Services.AddOpenApi(); + + await using var app = builder.Build(); + app.MapGet("/simple-error", static () => TypedResults.Problem()) + .ProducesPortableProblem(StatusCodes.Status400BadRequest, configure: x => x.WithErrorCodes("SimpleError")); + + var document = await GetOpenApiDocumentAsync(app); + var schemaComponent = GetSchemaComponent(document, "PortableError__SimpleError"); + var extension = (OpenApiSchema) schemaComponent.AllOf![1]; + + extension.Properties!.Keys.Should().BeEquivalentTo("code"); + extension.Properties.Should().NotContainKey("metadata"); + } + + [Fact] + public async Task Transformer_ShouldAcceptDuplicateInlineMetadataCode_WhenTypeIsIdentical() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/inline-duplicate", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status404NotFound, + configure: x => x + .WithErrorMetadata("Movie/Gone") + .WithErrorMetadata("Movie/Gone") + ); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var responseSchemaRef = GetResponseSchema( + document, + "/minimal/problems/inline-duplicate", + HttpMethod.Get, + StatusCodes.Status404NotFound, + "application/problem+json" + ).Should().BeOfType().Subject; + var component = GetSchemaComponent(document, GetSchemaReferenceId(responseSchemaRef)); + var items = + (OpenApiSchema) ((OpenApiSchema) ((OpenApiSchema) component.AllOf![1]).Properties!["errors"]).Items!; + + // Duplicate inline code with identical type must be deduplicated: only one documented variant + fallback. + items.AnyOf.Should().HaveCount(2); + } + + [Fact] + public async Task Transformer_ShouldThrow_WhenInlineMetadataCodesConflict() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/inline-conflict", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status404NotFound, + configure: x => x + .WithErrorMetadata("Movie/Gone") + .WithErrorMetadata("Movie/Gone") + ); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await GetOpenApiDocumentAsync(app); + + await act + .Should() + .ThrowAsync() + .WithMessage("*Movie/Gone*InlineProblemMetadata*ProblemMetadata*"); + } + + [Fact] + public async Task Transformer_ShouldThrow_WhenInlineCodesSanitizeToSameName() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/inline-sanitize-collision", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status404NotFound, + configure: x => x + .WithErrorMetadata("Movie/Gone") + .WithErrorMetadata("Movie_Gone") + ); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await GetOpenApiDocumentAsync(app); + + await act + .Should() + .ThrowAsync() + .WithMessage("*Movie/Gone*Movie_Gone*"); + } + [Fact] public void OpenApiModule_ShouldRegisterErrorMetadataRegistryOnlyOnce() { From 01af38858e2cd52c71bb935dbd9357e620783eb0 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 06:39:31 +0200 Subject: [PATCH 48/67] chore: increase code coverage for PortableErrorMetadataContract.GetHashCode Signed-off-by: Kenny Pflug --- .../PortableErrorMetadataContract.cs | 7 +---- .../PortableErrorMetadataContractTests.cs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs index 355db67..5bcc306 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs @@ -131,12 +131,7 @@ private static string CreateDiagnosticName( if (!string.IsNullOrWhiteSpace(declaringTypeName) && !string.IsNullOrWhiteSpace(methodName)) { - return declaringTypeName + "." + methodName; - } - - if (!string.IsNullOrWhiteSpace(methodName)) - { - return methodName; + return $"{declaringTypeName}.{methodName}"; } throw new InvalidOperationException( diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs index bdb3bd6..8e36ea7 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -179,6 +179,32 @@ public void ContractRegistry_ShouldRejectSanitizedCodeCollisions_WhenBuilderStat act.Should().Throw().WithMessage("*Code/One*Code_One*"); } + [Fact] + public void NoMetadataContract_ShouldReturn0_WhenGetHashCodeIsCalled() => + PortableErrorMetadataContract.NoMetadata.GetHashCode().Should().Be(0); + + [Fact] + public void PortableErrorMetadataSchemaContract_ShouldReturnHashCodeFromFactory() + { + var schemaContract = new PortableErrorMetadataSchemaContract(CreateSchema, null); + + var hashCode = schemaContract.GetHashCode(); + + var expectedHashCode = schemaContract.SchemaFactory.GetHashCode(); + hashCode.Should().Be(expectedHashCode); + } + + [Fact] + public void PortableErrorMetadataTypeContract_ShouldReturnHashCodeFromMetadataType() + { + var typeContract = new PortableErrorMetadataTypeContract(typeof(TestMetadata)); + + var hashCode = typeContract.GetHashCode(); + + var expectedHashCode = typeContract.MetadataType.GetHashCode(); + hashCode.Should().Be(expectedHashCode); + } + // ReSharper disable UnusedMember.Local -- required for testing private sealed class TestMetadata { From 2a4a0d5f5b64abbc71aa2924874688da13aa26f3 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 06:45:25 +0200 Subject: [PATCH 49/67] refactor: IPortableErrorMetadataContractRegistry now uses a FrozenDictionary Signed-off-by: Kenny Pflug --- ... DefaultPortableErrorMetadataContractRegistry.cs} | 12 ++++++------ .../IPortableErrorMetadataContractRegistry.cs | 4 ++-- .../PortableResultsOpenApiModule.cs | 2 +- .../PortableErrorMetadataContractTests.cs | 8 ++++---- .../BuiltInValidationErrorContractsTests.cs | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) rename src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/{PortableErrorMetadataContractRegistry.cs => DefaultPortableErrorMetadataContractRegistry.cs} (81%) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultPortableErrorMetadataContractRegistry.cs similarity index 81% rename from src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultPortableErrorMetadataContractRegistry.cs index 2c1ed5f..d4fff1f 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultPortableErrorMetadataContractRegistry.cs @@ -1,6 +1,6 @@ using System; +using System.Collections.Frozen; using System.Collections.Generic; -using System.Collections.ObjectModel; using Light.PortableResults.AspNetCore.OpenApi.Generation; using Light.PortableResults.AspNetCore.OpenApi.Schemas; @@ -9,13 +9,13 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Default implementation of . /// -public sealed class PortableErrorMetadataContractRegistry : IPortableErrorMetadataContractRegistry +public sealed class DefaultPortableErrorMetadataContractRegistry : IPortableErrorMetadataContractRegistry { /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// /// The builder that holds the configured contracts. - public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuilder builder) + public DefaultPortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -58,9 +58,9 @@ public PortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuild sanitizedCodes.Add(sanitizedCode, code); } - Contracts = new ReadOnlyDictionary(contracts); + Contracts = contracts.ToFrozenDictionary(); } /// - public IReadOnlyDictionary Contracts { get; } + public FrozenDictionary Contracts { get; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs index ea648e2..c694615 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Frozen; namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -10,5 +10,5 @@ public interface IPortableErrorMetadataContractRegistry /// /// Gets the immutable map of documented error codes to their metadata contracts. /// - IReadOnlyDictionary Contracts { get; } + FrozenDictionary Contracts { get; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs index 40113c3..1178c4d 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -56,7 +56,7 @@ private static void RegisterErrorMetadataContractRegistry(IServiceCollection ser { services.TryAddSingleton( static serviceProvider => - new PortableErrorMetadataContractRegistry( + new DefaultPortableErrorMetadataContractRegistry( serviceProvider.GetRequiredService>().Value.Builder ) ); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs index 8e36ea7..679a629 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -54,7 +54,7 @@ public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() builder.ForCode("NoMetadataCode"); builder.ForCode("NoMetadataCode"); - var registry = new PortableErrorMetadataContractRegistry(builder); + var registry = new DefaultPortableErrorMetadataContractRegistry(builder); registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode", "SchemaCode", "NoMetadataCode"); } @@ -137,7 +137,7 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() [Fact] public void ContractRegistry_ShouldExposePortableMetadataContracts() { - var registry = new PortableErrorMetadataContractRegistry( + var registry = new DefaultPortableErrorMetadataContractRegistry( new PortableErrorMetadataContractsBuilder().ForCode("TypeCode") ); @@ -150,7 +150,7 @@ public void ContractRegistry_ShouldSnapshotBuilderState() { var builder = new PortableErrorMetadataContractsBuilder().ForCode("TypeCode"); - var registry = new PortableErrorMetadataContractRegistry(builder); + var registry = new DefaultPortableErrorMetadataContractRegistry(builder); builder.ForCode("OtherCode"); registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode"); @@ -174,7 +174,7 @@ public void ContractRegistry_ShouldRejectSanitizedCodeCollisions_WhenBuilderStat contracts.Add("Code/One", PortableErrorMetadataContract.NoMetadata); contracts.Add("Code_One", PortableErrorMetadataContract.NoMetadata); - var act = () => _ = new PortableErrorMetadataContractRegistry(builder); + var act = () => _ = new DefaultPortableErrorMetadataContractRegistry(builder); act.Should().Throw().WithMessage("*Code/One*Code_One*"); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index 03aeadf..d1d3bed 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -190,7 +190,7 @@ public void RegisterBuiltInValidationErrors_ShouldRegisterExpectedCodes() var builder = new PortableErrorMetadataContractsBuilder(); builder.RegisterBuiltInValidationErrors(); - var registry = new PortableErrorMetadataContractRegistry(builder); + var registry = new DefaultPortableErrorMetadataContractRegistry(builder); registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); registry.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); @@ -203,7 +203,7 @@ public void RegisterBuiltInValidationErrors_ShouldBeIdempotent() builder.RegisterBuiltInValidationErrors(); builder.RegisterBuiltInValidationErrors(); - var registry = new PortableErrorMetadataContractRegistry(builder); + var registry = new DefaultPortableErrorMetadataContractRegistry(builder); registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); } From fb3c8db9655a29ec3060e4cec90100abc53c08b1 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 07:13:20 +0200 Subject: [PATCH 50/67] refactor: remove ConfigureErrorMetadataContracts and include registration delegate in AddPortableResultsOpenApi Signed-off-by: Kenny Pflug --- README.md | 13 +++---- samples/NativeAotMovieRating/Program.cs | 8 ++-- .../PortableResultsOpenApiMessages.cs | 2 +- .../PortableResultsOpenApiModule.cs | 31 +++++++--------- .../PortableResultsHttpWritingModule.cs | 4 +- .../PortableOpenApiResponseBuilderTests.cs | 3 +- ...eResultsOpenApiDocumentTransformerTests.cs | 37 ++++++++----------- .../ValidationOpenApiDocumentTestUtilities.cs | 3 +- 8 files changed, 42 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 86bc9dc..084e557 100644 --- a/README.md +++ b/README.md @@ -1346,10 +1346,9 @@ using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Validation.OpenApi; builder.Services + .AddOpenApi() .AddPortableResultsForMinimalApis() - .AddPortableResultsOpenApi() - .ConfigureErrorMetadataContracts(contracts => contracts.RegisterBuiltInValidationErrors()) - .AddOpenApi(); + .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. @@ -1383,13 +1382,13 @@ PortableResults OpenAPI metadata is authoritative for a given `(status code, con Top-level metadata and per-error-code metadata are caller-owned contracts. The OpenAPI package documents them explicitly; the runtime still writes `MetadataObject`. -For built-in validation errors, reference `Light.PortableResults.Validation.OpenApi` and register the catalog once: +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.ConfigureErrorMetadataContracts( +builder.Services.AddPortableResultsOpenApi( contracts => contracts.RegisterBuiltInValidationErrors() ); ``` @@ -1411,12 +1410,12 @@ app.MapPut("/api/movieRatings", AddMovieRating) Comparison and range codes are polymorphic at the global code level, so the validation bridge also ships typed endpoint helpers: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. 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: +Register reusable per-error-code metadata contracts once in DI by passing them to `AddPortableResultsOpenApi(...)`: ```csharp using Light.PortableResults.AspNetCore.OpenApi; -builder.Services.ConfigureErrorMetadataContracts(contracts => +builder.Services.AddPortableResultsOpenApi(contracts => { contracts.ForCode("VersionMismatch"); contracts.ForCode("InsufficientFunds"); diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index 41db9af..8fa4199 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -22,17 +22,15 @@ builder .Services .AddPortableResultsForMinimalApis() - .AddPortableResultsOpenApi() - .ConfigureErrorMetadataContracts(contracts => contracts.RegisterBuiltInValidationErrors()) .AddValidationForPortableResults() + .AddOpenApi() + .AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors()) .ConfigureJsonSerialization() .AddInMemoryDatabase() .AddGetMoviesModule() .AddNewMovieRatingModule() .AddNewMovieModule() - .AddHealthChecks() - .Services - .AddOpenApi(); + .AddHealthChecks(); var app = builder.Build(); app.UseSerilogRequestLogging(); diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index 3386f6c..6779b5b 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -19,7 +19,7 @@ string sanitizedCode $"The error codes '{firstCode}' and '{secondCode}' both sanitize to '{sanitizedCode}'. Error-code schema ids must be unique."; internal static string CreateUnknownErrorCodeMessage(string code) => - $"The error code '{code}' is not registered in ConfigureErrorMetadataContracts. Register it globally or use WithErrorMetadata as an inline escape hatch."; + $"The error code '{code}' is not registered in AddPortableResultsOpenApi. Register it globally or use WithErrorMetadata as an inline escape hatch."; internal static string CreateIncompleteInlineErrorMetadataMessage() => "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataTypes together."; diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs index 1178c4d..7a71542 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -15,14 +15,25 @@ namespace Light.PortableResults.AspNetCore.OpenApi; public static class PortableResultsOpenApiModule { /// - /// Registers the Light.PortableResults OpenAPI document transformer. + /// Registers the Light.PortableResults OpenAPI document transformer and optional global error metadata contracts. /// - public static IServiceCollection AddPortableResultsOpenApi(this IServiceCollection services) + public static IServiceCollection AddPortableResultsOpenApi( + this IServiceCollection services, + Action? configure = null + ) { ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton(); RegisterErrorMetadataContractRegistry(services); + if (configure is not null) + { + services.Configure(options => configure(options.Builder)); + } + else + { + services.AddOptions(); + } if (services.Any(static descriptor => descriptor.ServiceType == typeof(PortableResultsOpenApiRegistrationGate))) { @@ -36,22 +47,6 @@ public static IServiceCollection AddPortableResultsOpenApi(this IServiceCollecti return services; } - /// - /// Registers global error-code metadata contracts that endpoints can opt into. - /// - public static IServiceCollection ConfigureErrorMetadataContracts( - this IServiceCollection services, - Action configure - ) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configure); - - services.Configure(options => configure(options.Builder)); - RegisterErrorMetadataContractRegistry(services); - return services; - } - private static void RegisterErrorMetadataContractRegistry(IServiceCollection services) { services.TryAddSingleton( diff --git a/src/Light.PortableResults/Http/Writing/PortableResultsHttpWritingModule.cs b/src/Light.PortableResults/Http/Writing/PortableResultsHttpWritingModule.cs index 8974611..85380b2 100644 --- a/src/Light.PortableResults/Http/Writing/PortableResultsHttpWritingModule.cs +++ b/src/Light.PortableResults/Http/Writing/PortableResultsHttpWritingModule.cs @@ -34,9 +34,7 @@ public static IServiceCollection AddPortableResultHttpWriteOptions(this IService /// /// Thrown when is . /// - public static void AddDefaultPortableResultsHttpWriteJsonConverters( - this JsonSerializerOptions serializerOptions - ) + public static void AddDefaultPortableResultsHttpWriteJsonConverters(this JsonSerializerOptions serializerOptions) { if (serializerOptions is null) { diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index 140ea6d..95ed2ef 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -172,7 +172,7 @@ Action configureEndpoints var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddPortableResultsForMinimalApis(); - builder.Services.AddPortableResultsOpenApi(); + builder.Services.AddPortableResultsOpenApi(configureContracts); builder.Services.Configure( options => { @@ -181,7 +181,6 @@ Action configureEndpoints ValidationProblemSerializationFormat.AspNetCoreCompatible; } ); - builder.Services.ConfigureErrorMetadataContracts(configureContracts); builder.Services.AddOpenApi(); var app = builder.Build(); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 0da069f..0ab2de4 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -206,8 +206,8 @@ public async Task Transformer_ShouldThrowWhenAnEndpointUsesAnUnknownGlobalErrorC var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() - .ThrowAsync() - .WithMessage("*UnknownCode*ConfigureErrorMetadataContracts*WithErrorMetadata*"); + .ThrowAsync() + .WithMessage("*UnknownCode*AddPortableResultsOpenApi*WithErrorMetadata*"); } [Fact] @@ -474,8 +474,7 @@ public async Task Transformer_ShouldOmitMetadataProperty_WhenGlobalCodeHasNoMeta var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddPortableResultsForMinimalApis(); - builder.Services.AddPortableResultsOpenApi(); - builder.Services.ConfigureErrorMetadataContracts(contracts => contracts.ForCode("SimpleError")); + builder.Services.AddPortableResultsOpenApi(contracts => contracts.ForCode("SimpleError")); builder.Services.AddOpenApi(); await using var app = builder.Build(); @@ -576,19 +575,17 @@ await act } [Fact] - public void OpenApiModule_ShouldRegisterErrorMetadataRegistryOnlyOnce() + public void OpenApiModule_ShouldRegisterErrorMetadataRegistryOnlyOnce_WhenConfiguredViaAddPortableResultsOpenApi() { var services = new ServiceCollection(); services.AddOptions(); - services.ConfigureErrorMetadataContracts( + services.AddPortableResultsOpenApi( contracts => contracts.ForCode("VersionMismatch") ); - services.AddPortableResultsOpenApi(); - services.ConfigureErrorMetadataContracts( + services.AddPortableResultsOpenApi( contracts => contracts.ForCode("Insufficient/Funds") ); - services.AddPortableResultsOpenApi(); services.Where(static descriptor => descriptor.ServiceType == typeof(IPortableErrorMetadataContractRegistry)) .Should() @@ -615,7 +612,13 @@ private static WebApplication CreateMinimalApiApp( var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddPortableResultsForMinimalApis(); - builder.Services.AddPortableResultsOpenApi(); + builder.Services.AddPortableResultsOpenApi( + contracts => + { + contracts.ForCode("VersionMismatch"); + contracts.ForCode("Insufficient/Funds"); + } + ); builder.Services.Configure( options => { @@ -624,13 +627,6 @@ private static WebApplication CreateMinimalApiApp( ValidationProblemSerializationFormat.AspNetCoreCompatible; } ); - builder.Services.ConfigureErrorMetadataContracts( - contracts => - { - contracts.ForCode("VersionMismatch"); - contracts.ForCode("Insufficient/Funds"); - } - ); builder.Services.AddOpenApi(options => configureOpenApi?.Invoke(options)); var app = builder.Build(); @@ -677,7 +673,9 @@ private static WebApplication CreateMvcApp() var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddPortableResultsForMvc(); - builder.Services.AddPortableResultsOpenApi(); + builder.Services.AddPortableResultsOpenApi( + contracts => contracts.ForCode("VersionMismatch") + ); builder.Services.Configure( options => { @@ -686,9 +684,6 @@ private static WebApplication CreateMvcApp() ValidationProblemSerializationFormat.AspNetCoreCompatible; } ); - builder.Services.ConfigureErrorMetadataContracts( - contracts => contracts.ForCode("VersionMismatch") - ); builder.Services.AddControllers().AddApplicationPart(typeof(OpenApiMvcController).Assembly); builder.Services.AddOpenApi(); diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs index d934189..60dad74 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs @@ -28,11 +28,10 @@ internal static WebApplication CreateApp( var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddPortableResultsForMinimalApis(); - builder.Services.AddPortableResultsOpenApi(); + builder.Services.AddPortableResultsOpenApi(configureContracts); builder.Services.Configure( options => options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich ); - builder.Services.ConfigureErrorMetadataContracts(configureContracts); builder.Services.AddOpenApi(options => configureOpenApi?.Invoke(options)); var app = builder.Build(); From 885bb3773e0d832832202d3699658d0969cbb80d Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 08:08:45 +0200 Subject: [PATCH 51/67] fix: Replace typed NativeAOT-incompatible OpenAPI metadata with schema factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the nine generic CLR-surrogate record types (InRangeMetadata, EqualToMetadata, etc.) and their JsonSchemaExporter-based code path with programmatic OpenApiSchema construction, eliminating the NotSupportedException that occurred under NativeAOT. Key changes: - Add PortableOpenApiSchemaTypeMapper mapping CLR primitives → OpenApiSchema - Replace InlineErrorMetadataTypes (Type[]) with InlineErrorMetadataContracts (PortableErrorMetadataContract[]) on the attribute base class - Replace AppendTypes with AppendContracts in builder utilities - Add WithErrorMetadata(code, Func, [CallerArgumentExpression] diagnosticName) overload to both builder classes - Fix PortableErrorMetadataSchemaContract equality/hash to use DiagnosticName (ordinal) instead of lambda reference equality - Rewrite all 18 BuiltInValidationErrorBuilderExtensions helpers to use schema factories via PortableOpenApiSchemaTypeMapper - Delete BuiltInValidationErrorMetadata.cs (nine dead generic record types) - Update transformer inline loop and ValidateInlineMetadataArrays accordingly - Update all affected tests; delete BuiltInValidationErrorMetadataTests.cs; add idempotency test for repeated typed-helper registration - Add NativeAOT OpenAPI fix plan document Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Light.PortableResults.slnx | 1 + ...ility-for-built-in-validation-contracts.md | 51 +++++++ .../NativeAotMovieRating/packages.lock.json | 8 +- .../PortableErrorMetadataContract.cs | 5 +- ...rtableResultsOpenApiDocumentTransformer.cs | 23 ++- .../PortableResultsOpenApiMessages.cs | 2 +- .../PortableOpenApiBuilderUtilities.cs | 8 +- ...rtableOpenApiErrorResponseAttributeBase.cs | 6 +- .../PortableOpenApiSchemaTypeMapper.cs | 116 +++++++++++++++ .../PortableProblemOpenApiBuilder.cs | 32 +++- ...PortableValidationProblemOpenApiBuilder.cs | 32 +++- ...BuiltInValidationErrorBuilderExtensions.cs | 138 +++++++++++++++--- .../BuiltInValidationErrorMetadata.cs | 51 ------- .../PortableErrorMetadataContractTests.cs | 4 +- .../PortableOpenApiResponseBuilderTests.cs | 10 +- ...eResultsOpenApiDocumentTransformerTests.cs | 4 +- .../BuiltInValidationErrorMetadataTests.cs | 59 -------- .../PortableProblemOpenApiBuilderTests.cs | 25 ++++ 18 files changed, 407 insertions(+), 168 deletions(-) create mode 100644 ai-plans/0040-4-native-aot-compatibility-for-built-in-validation-contracts.md create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs delete mode 100644 src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs delete mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 2da6e6b..53448b2 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -53,6 +53,7 @@ + diff --git a/ai-plans/0040-4-native-aot-compatibility-for-built-in-validation-contracts.md b/ai-plans/0040-4-native-aot-compatibility-for-built-in-validation-contracts.md new file mode 100644 index 0000000..10d520b --- /dev/null +++ b/ai-plans/0040-4-native-aot-compatibility-for-built-in-validation-contracts.md @@ -0,0 +1,51 @@ +# NativeAOT Compatibility for Built-In Validation Contracts + +## Rationale + +When `.WithInRangeError()` (or any of the other typed comparison/range helpers) is called on an endpoint builder, it internally calls `WithErrorMetadata>(ValidationErrorCodes.InRange)`. That stores `typeof(InRangeMetadata)` as a `PortableErrorMetadataTypeContract`. At document-generation time the transformer calls `context.GetOrCreateSchemaAsync(typeof(InRangeMetadata), ...)`, which internally delegates to `JsonSchemaExporter.GetJsonSchemaAsNode(options, type, ...)`. In a NativeAOT application the JSON serializer is backed exclusively by a source-generated `JsonSerializerContext`, so any type not annotated with `[JsonSerializable]` on that context causes a `NotSupportedException` at startup. + +The global built-in contracts in `BuiltInValidationErrorContracts` are already NativeAOT-safe because they use `PortableErrorMetadataSchemaContract` (programmatic `OpenApiSchema` construction) and never call `JsonSchemaExporter`. The typed endpoint-scoped helpers are the only part of the library that still uses the CLR-type path, which is why the `NativeAotMovieRating` sample fails. The generic type parameter `T` on those helpers is only needed to derive the correct JSON primitive type for the schema properties; it is not needed at runtime and does not need to go through `JsonSchemaExporter`. + +The nine record types in `BuiltInValidationErrorMetadata.cs` (`EqualToMetadata`, `InRangeMetadata`, etc.) exist solely as CLR surrogates that used to feed the ASP.NET Core schema generator. Once the typed helpers switch to schema factories those surrogates are dead code and should be deleted. + +## Acceptance Criteria + +- [ ] `BuiltInValidationErrorMetadata.cs` is deleted from `Light.PortableResults.Validation.OpenApi`. The nine record types it contains are dead code after this change and must not be moved elsewhere. +- [ ] A `PortableOpenApiSchemaTypeMapper` **public** static class is added to `Light.PortableResults.AspNetCore.OpenApi`. It exposes two methods: `Map(Type type) : OpenApiSchema` for reflective/runtime call sites, and a generic `Map() : OpenApiSchema` that calls `Map(typeof(T))` for compile-time call sites. It maps a CLR `Type` to an `OpenApiSchema` covering at minimum: `int`/`short`/`long`/`byte`/`sbyte`/`uint`/`ushort`/`ulong` → `{ type: integer }`, `float`/`double`/`decimal` → `{ type: number }`, `bool` → `{ type: boolean }`, `string` → `{ type: string }`, `DateTime`/`DateTimeOffset` → `{ type: string, format: date-time }`, `DateOnly` → `{ type: string, format: date }`, `TimeOnly`/`TimeSpan` → `{ type: string, format: time }`, `Guid` → `{ type: string, format: uuid }`. For any `Nullable` or `T?` the mapper unwraps to the inner type. For any type not in the recognized set it falls back to an empty `OpenApiSchema` (no type constraint), preserving current graceful-degradation behavior. +- [ ] Each typed helper in `BuiltInValidationErrorBuilderExtensions` (all nine helpers on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`) is changed to call `WithErrorMetadata(string code, Func schemaFactory, [CallerArgumentExpression] string? diagnosticName)` instead of `WithErrorMetadata(string code)`. The schema factory uses `PortableOpenApiSchemaTypeMapper` to resolve the property schema(s) for `T` and constructs the same shaped `OpenApiSchema` that the global schema-based contracts in `BuiltInValidationErrorContracts` already produce, i.e. `{ type: object, properties: { lowerBoundary: , upperBoundary: }, required: [...] }` for range helpers, and `{ type: object, properties: { comparativeValue: }, required: [comparativeValue] }` for comparison helpers. The typed helpers pass an explicit diagnostic name string (e.g. `"InRangeMetadata"`) to uniquely identify the helper and its type argument. +- [ ] Two new `WithErrorMetadata` overloads are added to `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder` that accept `(string code, Func schemaFactory, [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null)`. These overloads create a `PortableErrorMetadataSchemaContract` and append it via a matching `AppendContracts` utility instead of storing a raw `Type`. The `[CallerArgumentExpression]` on `diagnosticName` mirrors the convention already used by `PortableErrorMetadataContractsBuilder.ForCode` and `PortableErrorMetadataContract.FromSchema`, so user-written inline schema factories get a useful diagnostic name automatically. +- [ ] `PortableErrorMetadataSchemaContract.Equals` and `GetHashCode` are updated to compare by `DiagnosticName` instead of by lambda reference (`ReferenceEquals`). Each call to `.WithInRangeError()` creates a fresh lambda closure, so reference equality would cause the duplicate-registration check to throw when the same typed helper is registered twice for the same endpoint code. Comparing by `DiagnosticName` restores the intended idempotency: two contracts with the same diagnostic name are treated as equivalent duplicates and the second registration is silently skipped. A test is added to confirm that registering the same typed helper twice on the same endpoint code is idempotent rather than throwing. +- [ ] `PortableOpenApiErrorResponseAttributeBase` replaces `Type[]? InlineErrorMetadataTypes` with `PortableErrorMetadataContract[]? InlineErrorMetadataContracts`. The existing `WithErrorMetadata(string code, Type metadataType)` overloads on both builders continue to work and store a `PortableErrorMetadataTypeContract` in the new array. +- [ ] `PortableOpenApiBuilderUtilities.AppendTypes` is replaced with `AppendContracts` accepting `PortableErrorMetadataContract[]?` and `PortableErrorMetadataContract`. All callers in the two builders are updated accordingly. +- [ ] `PortableResultsOpenApiDocumentTransformer` is updated to read `InlineErrorMetadataContracts` instead of `InlineErrorMetadataTypes`. The validation method `ValidateInlineMetadataArrays` and its error message are updated to reference the renamed property. +- [ ] The transformer's inline processing loop dispatches on the `PortableErrorMetadataContract` subclass for inline contracts, mirroring the existing dispatch it already performs for globally registered contracts. +- [ ] The `NativeAotMovieRating` sample successfully generates an OpenAPI document at startup without throwing `NotSupportedException`. +- [ ] Automated tests are updated: `PortableOpenApiResponseBuilderTests` references `InlineErrorMetadataContracts` (not `InlineErrorMetadataTypes`), and the partially-configured-arrays test in `PortableResultsOpenApiDocumentTransformerTests` is updated to match the renamed property. All existing typed-helper tests in `Light.PortableResults.Validation.OpenApi.Tests` continue to pass without modification beyond any mechanical test-helper updates caused by the removed record types. + +## Technical Details + +### Deleting the CLR Surrogates + +`BuiltInValidationErrorMetadata.cs` is a thin file of nine generic records. Simply delete it. The only callers are the nine typed helpers in `BuiltInValidationErrorBuilderExtensions`, which will be rewritten to use schema factories. No other production code in the repository references these records. + +### Type Mapper + +`PortableOpenApiSchemaTypeMapper` lives in `Light.PortableResults.AspNetCore.OpenApi` and is `public static`. This placement makes it available to all OpenAPI consumers regardless of whether they take the validation bridge package. It exposes two methods: `Map() : OpenApiSchema` for the nine typed helpers (no `typeof(T)` noise at every call site) and `Map(Type type) : OpenApiSchema` for reflective or runtime scenarios, with the generic overload simply delegating to the non-generic one. The recognized set listed in the acceptance criteria covers the types that `Light.PortableResults.Validation` actually constrains via `IsInBetween`, `IsGreaterThan`, etc. The mapper does not need to handle arrays, collections, or complex object types; those paths do not occur with built-in comparison/range constraints. The fallback to an empty `OpenApiSchema` keeps behavior consistent with what an unrecognized type would have produced through the ASP.NET Core schema generator. + +The mapper does not vary on `OpenApiSpecVersion` because the individual property schemas for integer/number/boolean/string primitives are the same in OpenAPI 3.0 and 3.1. Only `null` handling differs, and the built-in typed helpers do not emit a `nullable` metadata property. + +### Schema Contract Equality + +`PortableErrorMetadataSchemaContract.Equals` and `GetHashCode` currently use `ReferenceEquals` on the schema factory delegate. Each typed helper call creates a fresh lambda, so two logically identical calls would produce contracts that fail the equality check and trigger a spurious duplicate-registration exception. Change `Equals` to compare `DiagnosticName` (string equality, ordinal) and `GetHashCode` to return `DiagnosticName.GetHashCode(StringComparison.Ordinal)`. This is safe because `PortableErrorMetadataSchemaContract`'s constructor already guarantees a non-null, non-compiler-generated diagnostic name. + +### Attribute Change and Builder Plumbing + +The rename from `InlineErrorMetadataTypes: Type[]?` to `InlineErrorMetadataContracts: PortableErrorMetadataContract[]?` on `PortableOpenApiErrorResponseAttributeBase` is the central structural change. The two builder classes (`PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`) each gain one new `WithErrorMetadata` overload that accepts `(string code, Func schemaFactory, [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null)`, mirroring the convention already used by `PortableErrorMetadataContractsBuilder.ForCode` and `PortableErrorMetadataContract.FromSchema`. The existing `WithErrorMetadata(string code, Type metadataType)` overload wraps its argument in `PortableErrorMetadataContract.FromType(metadataType)` and stores it via `AppendContracts`. The new schema overload wraps its argument in `PortableErrorMetadataContract.FromSchema(schemaFactory, diagnosticName)` and stores it the same way. + +`PortableOpenApiBuilderUtilities.AppendTypes` is renamed to `AppendContracts` with the parameter types updated. The method body logic is identical. + +### Transformer Update + +The inline processing loop in the transformer already pattern-matches on `PortableErrorMetadataContract` subclasses for the global registry. Extend the same switch to the inline path: where it previously extracted `metadataType` from `InlineErrorMetadataTypes[i]` and called `PortableErrorMetadataContract.FromType(metadataType)`, it now directly reads `InlineErrorMetadataContracts[i]` which is already the correct concrete subclass. The dispatch logic then follows the identical pattern as the global path. + +The `ValidateInlineMetadataArrays` helper and its associated error message in `PortableResultsOpenApiMessages` need only a mechanical rename from `InlineErrorMetadataTypes` to `InlineErrorMetadataContracts`. diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 97ef157..4ecf302 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -183,20 +183,20 @@ "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } }, - "net10.0/osx-arm64": { + "net10.0/linux-x64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", "requested": "[10.0.7, )", "resolved": "10.0.7", "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==", "dependencies": { - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.7" + "runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.7" } }, - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { + "runtime.linux-x64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", "resolved": "10.0.7", - "contentHash": "ycFCaZwEvd0nNqcW53l0KWM+fz74owXpWj5C/z0GjznwAtHwmGTeh3vGTGFrXD9LEagX8G3cHRtzGDrTabIrwQ==" + "contentHash": "bz+Di9NJXvaWTvoma5Pf9JrgFj6MGkbPo9dlWRo+jOHXDEme511jeWVEBWoPdoDe6BjDWRngGi9P9EUBCCgzgw==" } } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs index 5bcc306..bad7045 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs @@ -103,10 +103,11 @@ public PortableErrorMetadataSchemaContract( /// public override bool Equals(object? obj) => - obj is PortableErrorMetadataSchemaContract other && ReferenceEquals(SchemaFactory, other.SchemaFactory); + obj is PortableErrorMetadataSchemaContract other && + string.Equals(DiagnosticName, other.DiagnosticName, StringComparison.Ordinal); /// - public override int GetHashCode() => SchemaFactory.GetHashCode(); + public override int GetHashCode() => DiagnosticName.GetHashCode(StringComparison.Ordinal); private static string CreateDiagnosticName( Func schemaFactory, diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 022d947..ba74fc0 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -413,8 +413,8 @@ CancellationToken cancellationToken } var inlineCodes = attribute.InlineErrorMetadataCodes; - var inlineTypes = attribute.InlineErrorMetadataTypes; - if (inlineCodes is not null && inlineTypes is not null) + var inlineContracts = attribute.InlineErrorMetadataContracts; + if (inlineCodes is not null && inlineContracts is not null) { // Inline contracts are declared directly on the endpoint, so this branch has to create // endpoint-specific schemas and guard against both raw-code duplicates and sanitized-name @@ -422,7 +422,7 @@ CancellationToken cancellationToken for (var i = 0; i < inlineCodes.Length; i++) { var code = inlineCodes[i]; - var metadataType = inlineTypes[i]; + var contract = inlineContracts[i]; var sanitizedCode = PortableResultsOpenApiSchemaNaming.SanitizeErrorCode(code); if (inlineSanitizedCodes.TryGetValue(sanitizedCode, out var existingCode) && !string.Equals(existingCode, code, StringComparison.Ordinal)) @@ -436,16 +436,15 @@ CancellationToken cancellationToken ); } - var metadataTypeContract = PortableErrorMetadataContract.FromType(metadataType); if (rawCodeContracts.TryGetValue(code, out var existingContract)) { - if (!existingContract.Equals(metadataTypeContract)) + if (!existingContract.Equals(contract)) { throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( code, existingContract, - metadataTypeContract + contract ) ); } @@ -453,7 +452,7 @@ CancellationToken cancellationToken continue; } - rawCodeContracts.Add(code, metadataTypeContract); + rawCodeContracts.Add(code, contract); inlineSanitizedCodes.TryAdd(sanitizedCode, code); var schemaId = PortableResultsOpenApiSchemaNaming.CreateInlineErrorSchemaId( itemBaseSchemaId, @@ -469,7 +468,7 @@ CancellationToken cancellationToken itemBaseSchemaId, schemaId, code, - metadataTypeContract, + contract, openApiVersion, cancellationToken ); @@ -784,25 +783,25 @@ private static string ResolveErrorItemSchemaId(string canonicalEnvelopeSchemaId) private static void ValidateInlineMetadataArrays(PortableOpenApiErrorResponseAttributeBase attribute) { - if (attribute.InlineErrorMetadataCodes is null && attribute.InlineErrorMetadataTypes is null) + if (attribute.InlineErrorMetadataCodes is null && attribute.InlineErrorMetadataContracts is null) { return; } - if (attribute.InlineErrorMetadataCodes is null || attribute.InlineErrorMetadataTypes is null) + if (attribute.InlineErrorMetadataCodes is null || attribute.InlineErrorMetadataContracts is null) { throw new InvalidOperationException( PortableResultsOpenApiMessages.CreateIncompleteInlineErrorMetadataMessage() ); } - if (attribute.InlineErrorMetadataCodes.Length == attribute.InlineErrorMetadataTypes.Length) + if (attribute.InlineErrorMetadataCodes.Length == attribute.InlineErrorMetadataContracts.Length) { return; } throw new InvalidOperationException( - $"Inline error metadata arrays must have the same length, but codes has length {attribute.InlineErrorMetadataCodes.Length} and types has length {attribute.InlineErrorMetadataTypes.Length}." + $"Inline error metadata arrays must have the same length, but codes has length {attribute.InlineErrorMetadataCodes.Length} and contracts has length {attribute.InlineErrorMetadataContracts.Length}." ); } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index 6779b5b..dbc4198 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -22,7 +22,7 @@ internal static string CreateUnknownErrorCodeMessage(string code) => $"The error code '{code}' is not registered in AddPortableResultsOpenApi. Register it globally or use WithErrorMetadata as an inline escape hatch."; internal static string CreateIncompleteInlineErrorMetadataMessage() => - "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataTypes together."; + "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataContracts together."; private static string DescribeContract(PortableErrorMetadataContract contract) { diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs index 5aa3dc9..bc7d916 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs @@ -1,4 +1,5 @@ using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; namespace Light.PortableResults.AspNetCore.OpenApi; @@ -41,7 +42,10 @@ internal static string[] AppendStrings(string[]? existingValues, string[] newVal return combinedValues; } - internal static Type[] AppendTypes(Type[]? existingValues, Type newValue) + internal static PortableErrorMetadataContract[] AppendContracts( + PortableErrorMetadataContract[]? existingValues, + PortableErrorMetadataContract newValue + ) { ArgumentNullException.ThrowIfNull(newValue); @@ -50,7 +54,7 @@ internal static Type[] AppendTypes(Type[]? existingValues, Type newValue) return [newValue]; } - var combinedValues = new Type[existingValues.Length + 1]; + var combinedValues = new PortableErrorMetadataContract[existingValues.Length + 1]; Array.Copy(existingValues, combinedValues, existingValues.Length); combinedValues[^1] = newValue; return combinedValues; diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs index c16fbd0..8ef45e1 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs @@ -1,4 +1,4 @@ -using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; namespace Light.PortableResults.AspNetCore.OpenApi; @@ -30,8 +30,8 @@ string contentType public string[]? InlineErrorMetadataCodes { get; set; } /// - /// Gets or sets the inline metadata CLR types aligned by index with + /// Gets or sets the inline metadata contracts aligned by index with /// . /// - public Type[]? InlineErrorMetadataTypes { get; set; } + public PortableErrorMetadataContract[]? InlineErrorMetadataContracts { get; set; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs new file mode 100644 index 0000000..a392954 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Maps CLR types to OpenAPI schema primitives for use in schema factories. +/// +public static class PortableOpenApiSchemaTypeMapper +{ + /// + /// Returns an that represents the JSON primitive type for + /// the given CLR . + /// + /// + /// Recognized types and their schemas: + /// + /// + /// int, short, long, byte, sbyte, uint, ushort, ulong + /// { type: integer } + /// + /// + /// float, double, decimal + /// { type: number } + /// + /// + /// bool + /// { type: boolean } + /// + /// + /// string + /// { type: string } + /// + /// + /// DateTime, DateTimeOffset + /// { type: string, format: date-time } + /// + /// + /// DateOnly + /// { type: string, format: date } + /// + /// + /// TimeOnly, TimeSpan + /// { type: string, format: time } + /// + /// + /// Guid + /// { type: string, format: uuid } + /// + /// + /// + /// Nullable<T> is unwrapped to its inner type before mapping. + /// Any type not in the recognized set maps to an empty with no type + /// constraint, preserving graceful-degradation behavior. + /// + /// + public static OpenApiSchema Map(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + var underlyingType = Nullable.GetUnderlyingType(type); + if (underlyingType is not null) + { + return Map(underlyingType); + } + + if (type == typeof(int) || type == typeof(short) || type == typeof(long) || + type == typeof(byte) || type == typeof(sbyte) || + type == typeof(uint) || type == typeof(ushort) || type == typeof(ulong)) + { + return new OpenApiSchema { Type = JsonSchemaType.Integer }; + } + + if (type == typeof(float) || type == typeof(double) || type == typeof(decimal)) + { + return new OpenApiSchema { Type = JsonSchemaType.Number }; + } + + if (type == typeof(bool)) + { + return new OpenApiSchema { Type = JsonSchemaType.Boolean }; + } + + if (type == typeof(string)) + { + return new OpenApiSchema { Type = JsonSchemaType.String }; + } + + if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" }; + } + + if (type == typeof(DateOnly)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "date" }; + } + + if (type == typeof(TimeOnly) || type == typeof(TimeSpan)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "time" }; + } + + if (type == typeof(Guid)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid" }; + } + + return new OpenApiSchema(); + } + + /// + /// Returns an that represents the JSON primitive type for . + /// + public static OpenApiSchema Map() => Map(typeof(T)); +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index bc9128c..4d3ac9c 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -1,4 +1,7 @@ using System; +using System.Runtime.CompilerServices; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Microsoft.OpenApi; namespace Light.PortableResults.AspNetCore.OpenApi; @@ -51,9 +54,9 @@ public PortableProblemOpenApiBuilder WithErrorMetadata(string code, Type metadat _attribute.InlineErrorMetadataCodes, code ); - _attribute.InlineErrorMetadataTypes = PortableOpenApiBuilderUtilities.AppendTypes( - _attribute.InlineErrorMetadataTypes, - metadataType + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + PortableErrorMetadataContract.FromType(metadataType) ); return this; } @@ -65,4 +68,27 @@ public PortableProblemOpenApiBuilder WithErrorMetadata(string code) { return WithErrorMetadata(code, typeof(TMetadata)); } + + /// + /// Registers an inline schema-factory metadata contract for the specified error code. + /// + public PortableProblemOpenApiBuilder WithErrorMetadata( + string code, + Func schemaFactory, + [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + ) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(schemaFactory); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + PortableErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) + ); + return this; + } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 364f851..18c109c 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -1,5 +1,8 @@ using System; +using System.Runtime.CompilerServices; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Light.PortableResults.Http.Writing; +using Microsoft.OpenApi; namespace Light.PortableResults.AspNetCore.OpenApi; @@ -53,9 +56,9 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code, Ty _attribute.InlineErrorMetadataCodes, code ); - _attribute.InlineErrorMetadataTypes = PortableOpenApiBuilderUtilities.AppendTypes( - _attribute.InlineErrorMetadataTypes, - metadataType + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + PortableErrorMetadataContract.FromType(metadataType) ); return this; } @@ -68,6 +71,29 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata(stri return WithErrorMetadata(code, typeof(TMetadata)); } + /// + /// Registers an inline schema-factory metadata contract for the specified error code. + /// + public PortableValidationProblemOpenApiBuilder WithErrorMetadata( + string code, + Func schemaFactory, + [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + ) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(schemaFactory); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + PortableErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) + ); + return this; + } + /// /// Overrides the documented validation problem serialization format for this endpoint. /// diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs index af890f1..606047a 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation.Definitions; +using Microsoft.OpenApi; namespace Light.PortableResults.Validation.OpenApi; @@ -10,107 +13,198 @@ public static class BuiltInValidationErrorBuilderExtensions { /// Documents endpoint-specific EqualTo validation error metadata. public static PortableProblemOpenApiBuilder WithEqualToError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.EqualTo); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.EqualTo, + _ => CreateComparisonSchema(), + $"EqualToMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific EqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithEqualToError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.EqualTo); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.EqualTo, + _ => CreateComparisonSchema(), + $"EqualToMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific NotEqualTo validation error metadata. public static PortableProblemOpenApiBuilder WithNotEqualToError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotEqualTo); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.NotEqualTo, + _ => CreateComparisonSchema(), + $"NotEqualToMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific NotEqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithNotEqualToError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotEqualTo); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.NotEqualTo, + _ => CreateComparisonSchema(), + $"NotEqualToMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific GreaterThan validation error metadata. public static PortableProblemOpenApiBuilder WithGreaterThanError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.GreaterThan); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.GreaterThan, + _ => CreateComparisonSchema(), + $"GreaterThanMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific GreaterThan validation error metadata. public static PortableValidationProblemOpenApiBuilder WithGreaterThanError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.GreaterThan); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.GreaterThan, + _ => CreateComparisonSchema(), + $"GreaterThanMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. public static PortableProblemOpenApiBuilder WithGreaterThanOrEqualToError( this PortableProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>( - ValidationErrorCodes.GreaterThanOrEqualTo + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.GreaterThanOrEqualTo, + _ => CreateComparisonSchema(), + $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" ); /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithGreaterThanOrEqualToError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>( - ValidationErrorCodes.GreaterThanOrEqualTo + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.GreaterThanOrEqualTo, + _ => CreateComparisonSchema(), + $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" ); /// Documents endpoint-specific LessThan validation error metadata. public static PortableProblemOpenApiBuilder WithLessThanError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.LessThan); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.LessThan, + _ => CreateComparisonSchema(), + $"LessThanMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific LessThan validation error metadata. public static PortableValidationProblemOpenApiBuilder WithLessThanError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.LessThan); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.LessThan, + _ => CreateComparisonSchema(), + $"LessThanMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. public static PortableProblemOpenApiBuilder WithLessThanOrEqualToError( this PortableProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>( - ValidationErrorCodes.LessThanOrEqualTo + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.LessThanOrEqualTo, + _ => CreateComparisonSchema(), + $"LessThanOrEqualToMetadata<{typeof(T).Name}>" ); /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithLessThanOrEqualToError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>( - ValidationErrorCodes.LessThanOrEqualTo + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.LessThanOrEqualTo, + _ => CreateComparisonSchema(), + $"LessThanOrEqualToMetadata<{typeof(T).Name}>" ); /// Documents endpoint-specific InRange validation error metadata. public static PortableProblemOpenApiBuilder WithInRangeError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.InRange); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.InRange, + _ => CreateRangeSchema(), + $"InRangeMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific InRange validation error metadata. public static PortableValidationProblemOpenApiBuilder WithInRangeError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.InRange); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.InRange, + _ => CreateRangeSchema(), + $"InRangeMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific NotInRange validation error metadata. public static PortableProblemOpenApiBuilder WithNotInRangeError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotInRange); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.NotInRange, + _ => CreateRangeSchema(), + $"NotInRangeMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific NotInRange validation error metadata. public static PortableValidationProblemOpenApiBuilder WithNotInRangeError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.NotInRange); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.NotInRange, + _ => CreateRangeSchema(), + $"NotInRangeMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific ExclusiveRange validation error metadata. public static PortableProblemOpenApiBuilder WithExclusiveRangeError( this PortableProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.ExclusiveRange); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.ExclusiveRange, + _ => CreateRangeSchema(), + $"ExclusiveRangeMetadata<{typeof(T).Name}>" + ); /// Documents endpoint-specific ExclusiveRange validation error metadata. public static PortableValidationProblemOpenApiBuilder WithExclusiveRangeError( this PortableValidationProblemOpenApiBuilder builder ) => - EnsureBuilder(builder).WithErrorMetadata>(ValidationErrorCodes.ExclusiveRange); + EnsureBuilder(builder).WithErrorMetadata( + ValidationErrorCodes.ExclusiveRange, + _ => CreateRangeSchema(), + $"ExclusiveRangeMetadata<{typeof(T).Name}>" + ); + + private static OpenApiSchema CreateComparisonSchema() => + new() + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.ComparativeValue] = PortableOpenApiSchemaTypeMapper.Map() + }, + Required = new HashSet(StringComparer.Ordinal) { ValidationErrorMetadataKeys.ComparativeValue } + }; + + private static OpenApiSchema CreateRangeSchema() => + new() + { + Type = JsonSchemaType.Object, + Properties = new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.LowerBoundary] = PortableOpenApiSchemaTypeMapper.Map(), + [ValidationErrorMetadataKeys.UpperBoundary] = PortableOpenApiSchemaTypeMapper.Map() + }, + Required = new HashSet(StringComparer.Ordinal) + { + ValidationErrorMetadataKeys.LowerBoundary, + ValidationErrorMetadataKeys.UpperBoundary + } + }; private static PortableProblemOpenApiBuilder EnsureBuilder(PortableProblemOpenApiBuilder builder) { diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs deleted file mode 100644 index 1abc258..0000000 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorMetadata.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Light.PortableResults.Validation.OpenApi; - -/// Metadata contract for endpoint-specific EqualTo validation errors. -/// The expected value. -public sealed record EqualToMetadata([property: Required] T ComparativeValue); - -/// Metadata contract for endpoint-specific NotEqualTo validation errors. -/// The disallowed value. -public sealed record NotEqualToMetadata([property: Required] T ComparativeValue); - -/// Metadata contract for endpoint-specific GreaterThan validation errors. -/// The lower exclusive boundary. -public sealed record GreaterThanMetadata([property: Required] T ComparativeValue); - -/// Metadata contract for endpoint-specific GreaterThanOrEqualTo validation errors. -/// The lower inclusive boundary. -public sealed record GreaterThanOrEqualToMetadata([property: Required] T ComparativeValue); - -/// Metadata contract for endpoint-specific LessThan validation errors. -/// The upper exclusive boundary. -public sealed record LessThanMetadata([property: Required] T ComparativeValue); - -/// Metadata contract for endpoint-specific LessThanOrEqualTo validation errors. -/// The upper inclusive boundary. -public sealed record LessThanOrEqualToMetadata([property: Required] T ComparativeValue); - -/// Metadata contract for endpoint-specific InRange validation errors. -/// The lower boundary. -/// The upper boundary. -public sealed record InRangeMetadata( - [property: Required] T LowerBoundary, - [property: Required] T UpperBoundary -); - -/// Metadata contract for endpoint-specific NotInRange validation errors. -/// The lower boundary. -/// The upper boundary. -public sealed record NotInRangeMetadata( - [property: Required] T LowerBoundary, - [property: Required] T UpperBoundary -); - -/// Metadata contract for endpoint-specific ExclusiveRange validation errors. -/// The lower exclusive boundary. -/// The upper exclusive boundary. -public sealed record ExclusiveRangeMetadata( - [property: Required] T LowerBoundary, - [property: Required] T UpperBoundary -); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs index 679a629..2773a8d 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs @@ -184,13 +184,13 @@ public void NoMetadataContract_ShouldReturn0_WhenGetHashCodeIsCalled() => PortableErrorMetadataContract.NoMetadata.GetHashCode().Should().Be(0); [Fact] - public void PortableErrorMetadataSchemaContract_ShouldReturnHashCodeFromFactory() + public void PortableErrorMetadataSchemaContract_ShouldReturnHashCodeFromDiagnosticName() { var schemaContract = new PortableErrorMetadataSchemaContract(CreateSchema, null); var hashCode = schemaContract.GetHashCode(); - var expectedHashCode = schemaContract.SchemaFactory.GetHashCode(); + var expectedHashCode = schemaContract.DiagnosticName.GetHashCode(StringComparison.Ordinal); hashCode.Should().Be(expectedHashCode); } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index 95ed2ef..f1f62d2 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -42,7 +42,10 @@ public void ProducesPortableProblem_ShouldAccumulateRouteMetadata() attribute.TopLevelMetadataType.Should().Be(typeof(ProblemMetadata)); attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); - attribute.InlineErrorMetadataTypes.Should().Equal(typeof(InlineProblemMetadata), typeof(ProblemMetadata)); + attribute.InlineErrorMetadataContracts.Should().Equal( + PortableErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), + PortableErrorMetadataContract.FromType(typeof(ProblemMetadata)) + ); } [Fact] @@ -66,7 +69,10 @@ public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() attribute.TopLevelMetadataType.Should().Be(typeof(ProblemMetadata)); attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); - attribute.InlineErrorMetadataTypes.Should().Equal(typeof(InlineProblemMetadata), typeof(ProblemMetadata)); + attribute.InlineErrorMetadataContracts.Should().Equal( + PortableErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), + PortableErrorMetadataContract.FromType(typeof(ProblemMetadata)) + ); attribute.Format.Should().Be(ValidationProblemSerializationFormat.Rich); attribute.HasFormatOverride.Should().BeTrue(); } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 0ab2de4..23f5e26 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -440,7 +440,7 @@ bool configureCodes } else { - attribute.InlineErrorMetadataTypes = new[] { typeof(InlineProblemMetadata) }; + attribute.InlineErrorMetadataContracts = [PortableErrorMetadataContract.FromType(typeof(InlineProblemMetadata))]; } webApplication @@ -454,7 +454,7 @@ bool configureCodes await act.Should() .ThrowAsync() - .WithMessage("*InlineErrorMetadataCodes*InlineErrorMetadataTypes*"); + .WithMessage("*InlineErrorMetadataCodes*InlineErrorMetadataContracts*"); } [Fact] diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs deleted file mode 100644 index 608bf8c..0000000 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorMetadataTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using FluentAssertions; -using Light.PortableResults.Validation.Definitions; -using Xunit; - -namespace Light.PortableResults.Validation.OpenApi.Tests; - -public sealed class BuiltInValidationErrorMetadataTests -{ - private static readonly JsonSerializerOptions SerializerOptions = new () - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - public static TheoryData MetadataCases => - new () - { - { new EqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, - { new NotEqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, - { new GreaterThanMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, - { new GreaterThanOrEqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, - { new LessThanMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, - { new LessThanOrEqualToMetadata(42), [ValidationErrorMetadataKeys.ComparativeValue] }, - { - new InRangeMetadata( - new DateTime(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc), - new DateTime(2024, 2, 4, 4, 5, 6, DateTimeKind.Utc) - ), - [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] - }, - { - new NotInRangeMetadata(1, 10), - [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] - }, - { - new ExclusiveRangeMetadata(1, 10), - [ValidationErrorMetadataKeys.LowerBoundary, ValidationErrorMetadataKeys.UpperBoundary] - } - }; - - [Theory] - [MemberData(nameof(MetadataCases))] - public void MetadataRecords_ShouldSerializeExpectedProperties(object metadata, string[] expectedProperties) - { - var payload = JsonNode.Parse(JsonSerializer.Serialize(metadata, SerializerOptions)) - .Should() - .BeOfType() - .Subject; - - payload.Select(static pair => pair.Key).Should().BeEquivalentTo(expectedProperties); - foreach (var property in expectedProperties) - { - payload[property].Should().NotBeNull(); - } - } -} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs index de261e2..cfe02ca 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs @@ -100,6 +100,31 @@ public async Task ProducesPortableProblem_ShouldMixGlobalAndEndpointScopedBuiltI ); } + [Fact] + public async Task TypedHelpers_ShouldBeIdempotentWhenRegisteredTwice() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + _ => { }, + endpoints => + { + endpoints + .MapGet("/idempotent-problem", static () => Results.Problem()) + .WithName("IdempotentProblem") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: builder => + { + builder.WithInRangeError(); + builder.WithInRangeError(); + } + ); + } + ); + + var act = async () => await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + await act.Should().NotThrowAsync(); + } + private static WebApplication CreateTypedHelperApp(string operationName) { return ValidationOpenApiDocumentTestUtilities.CreateApp( From 7411ee184fbbc91d6fc308e2ffe37157741a7d87 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 10:02:41 +0200 Subject: [PATCH 52/67] chore: rename to ErrorMetadataContract Signed-off-by: Kenny Pflug --- ...> DefaultErrorMetadataContractRegistry.cs} | 12 +- .../ErrorContracts/ErrorMetadataContract.cs | 44 +++++ ...er.cs => ErrorMetadataContractsBuilder.cs} | 25 +-- ...ns.cs => ErrorMetadataContractsOptions.cs} | 4 +- .../ErrorMetadataSchemaContract.cs | 76 +++++++++ .../ErrorMetadataTypeContract.cs | 31 ++++ ...y.cs => IErrorMetadataContractRegistry.cs} | 4 +- .../ErrorContracts/NoMetadataContract.cs | 15 ++ .../PortableErrorMetadataContract.cs | 157 ------------------ ...rtableResultsOpenApiDocumentTransformer.cs | 20 +-- .../PortableResultsOpenApiMessages.cs | 14 +- .../PortableOpenApiBuilderUtilities.cs | 8 +- ...rtableOpenApiErrorResponseAttributeBase.cs | 2 +- .../PortableProblemOpenApiBuilder.cs | 4 +- .../PortableResultsOpenApiModule.cs | 12 +- ...PortableValidationProblemOpenApiBuilder.cs | 4 +- ...tionErrorContractRegistrationExtensions.cs | 10 +- .../BuiltInValidationErrorContracts.cs | 26 +-- ...Tests.cs => ErrorMetadataContractTests.cs} | 77 ++++----- .../PortableOpenApiResponseBuilderTests.cs | 10 +- ...eResultsOpenApiDocumentTransformerTests.cs | 15 +- .../BuiltInValidationErrorContractsTests.cs | 19 ++- .../ValidationOpenApiDocumentTestUtilities.cs | 2 +- 23 files changed, 303 insertions(+), 288 deletions(-) rename src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/{DefaultPortableErrorMetadataContractRegistry.cs => DefaultErrorMetadataContractRegistry.cs} (78%) create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs rename src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/{PortableErrorMetadataContractsBuilder.cs => ErrorMetadataContractsBuilder.cs} (73%) rename src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/{PortableErrorMetadataContractsOptions.cs => ErrorMetadataContractsOptions.cs} (67%) create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataTypeContract.cs rename src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/{IPortableErrorMetadataContractRegistry.cs => IErrorMetadataContractRegistry.cs} (71%) create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/NoMetadataContract.cs delete mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs rename tests/Light.PortableResults.AspNetCore.OpenApi.Tests/{PortableErrorMetadataContractTests.cs => ErrorMetadataContractTests.cs} (66%) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultPortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultErrorMetadataContractRegistry.cs similarity index 78% rename from src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultPortableErrorMetadataContractRegistry.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultErrorMetadataContractRegistry.cs index d4fff1f..77a6bcc 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultPortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultErrorMetadataContractRegistry.cs @@ -7,19 +7,19 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// -/// Default implementation of . +/// Default implementation of . /// -public sealed class DefaultPortableErrorMetadataContractRegistry : IPortableErrorMetadataContractRegistry +public sealed class DefaultErrorMetadataContractRegistry : IErrorMetadataContractRegistry { /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// /// The builder that holds the configured contracts. - public DefaultPortableErrorMetadataContractRegistry(PortableErrorMetadataContractsBuilder builder) + public DefaultErrorMetadataContractRegistry(ErrorMetadataContractsBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - var contracts = new Dictionary(StringComparer.Ordinal); + var contracts = new Dictionary(StringComparer.Ordinal); var sanitizedCodes = new Dictionary(StringComparer.Ordinal); foreach (var (code, contract) in builder.Contracts) { @@ -62,5 +62,5 @@ public DefaultPortableErrorMetadataContractRegistry(PortableErrorMetadataContrac } /// - public FrozenDictionary Contracts { get; } + public FrozenDictionary Contracts { get; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs new file mode 100644 index 0000000..a8d905a --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs @@ -0,0 +1,44 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Represents a documented metadata contract for a portable error code. +/// +public abstract class ErrorMetadataContract +{ + private protected ErrorMetadataContract() { } + + /// + /// Gets the singleton contract for error codes that do not emit metadata. + /// + public static ErrorMetadataContract NoMetadata { get; } = new NoMetadataContract(); + + /// + /// Creates a contract backed by a CLR metadata type. + /// + /// The CLR metadata type. + /// The metadata contract. + public static ErrorMetadataContract FromType(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + return new ErrorMetadataTypeContract(metadataType); + } + + /// + /// Creates a contract backed by a schema factory. + /// + /// The factory that creates a fresh metadata schema for the requested OpenAPI version. + /// The optional diagnostic name used in duplicate-contract errors. + /// The metadata contract. + public static ErrorMetadataContract FromSchema( + Func schemaFactory, + [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + ) + { + ArgumentNullException.ThrowIfNull(schemaFactory); + return new ErrorMetadataSchemaContract(schemaFactory, diagnosticName); + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs similarity index 73% rename from src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs index 9eca1b6..56a2a1c 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs @@ -10,17 +10,20 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Builds the global map of documented error-code metadata contracts. /// -public sealed class PortableErrorMetadataContractsBuilder +public sealed class ErrorMetadataContractsBuilder { - private readonly Dictionary _contracts = new (StringComparer.Ordinal); + private readonly Dictionary _contracts = new (StringComparer.Ordinal); private readonly Dictionary _sanitizedCodes = new (StringComparer.Ordinal); - internal IReadOnlyDictionary Contracts => _contracts; + /// + /// Gets the immutable map of documented error codes to their metadata contracts. + /// + public IReadOnlyDictionary Contracts => _contracts; /// /// Registers as the metadata contract for the specified code. /// - public PortableErrorMetadataContractsBuilder ForCode(string code) + public ErrorMetadataContractsBuilder ForCode(string code) { return ForCode(code, typeof(TMetadata)); } @@ -28,9 +31,9 @@ public PortableErrorMetadataContractsBuilder ForCode(string code) /// /// Registers the specified CLR metadata type for the specified code. /// - public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataType) + public ErrorMetadataContractsBuilder ForCode(string code, Type metadataType) { - return ForCode(code, PortableErrorMetadataContract.FromType(metadataType)); + return ForCode(code, ErrorMetadataContract.FromType(metadataType)); } /// @@ -39,24 +42,24 @@ public PortableErrorMetadataContractsBuilder ForCode(string code, Type metadataT /// The error code to register. /// The factory that creates a fresh metadata schema for the requested OpenAPI version. /// The optional diagnostic name used in duplicate-contract errors. - public PortableErrorMetadataContractsBuilder ForCode( + public ErrorMetadataContractsBuilder ForCode( string code, Func metadataSchemaFactory, [CallerArgumentExpression(nameof(metadataSchemaFactory))] string? diagnosticName = null ) { - return ForCode(code, PortableErrorMetadataContract.FromSchema(metadataSchemaFactory, diagnosticName)); + return ForCode(code, ErrorMetadataContract.FromSchema(metadataSchemaFactory, diagnosticName)); } /// /// Registers the specified code as a code that emits no metadata. /// - public PortableErrorMetadataContractsBuilder ForCode(string code) + public ErrorMetadataContractsBuilder ForCode(string code) { - return ForCode(code, PortableErrorMetadataContract.NoMetadata); + return ForCode(code, ErrorMetadataContract.NoMetadata); } - internal PortableErrorMetadataContractsBuilder ForCode(string code, PortableErrorMetadataContract contract) + private ErrorMetadataContractsBuilder ForCode(string code, ErrorMetadataContract contract) { ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentNullException.ThrowIfNull(contract); diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsOptions.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsOptions.cs similarity index 67% rename from src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsOptions.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsOptions.cs index a43a7d3..5bffaa9 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContractsOptions.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsOptions.cs @@ -3,10 +3,10 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Options backing the global error-code metadata contract registry. /// -public sealed class PortableErrorMetadataContractsOptions +public sealed class ErrorMetadataContractsOptions { /// /// Gets the mutable builder populated through the options pipeline. /// - public PortableErrorMetadataContractsBuilder Builder { get; } = new (); + public ErrorMetadataContractsBuilder Builder { get; } = new (); } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs new file mode 100644 index 0000000..f381d53 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs @@ -0,0 +1,76 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Represents a metadata contract backed by an OpenAPI schema factory. +/// +public sealed class ErrorMetadataSchemaContract : ErrorMetadataContract +{ + /// + /// Initializes a new instance of . + /// + /// The factory that creates a fresh metadata schema for the requested OpenAPI version. + /// The optional diagnostic name used in duplicate-contract errors. + public ErrorMetadataSchemaContract( + Func schemaFactory, + [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + ) + { + ArgumentNullException.ThrowIfNull(schemaFactory); + SchemaFactory = schemaFactory; + DiagnosticName = CreateDiagnosticName(schemaFactory, diagnosticName); + } + + /// + /// Gets the factory that creates a fresh metadata schema for the requested OpenAPI version. + /// + public Func SchemaFactory { get; } + + /// + /// Gets the diagnostic name used in duplicate-contract errors. + /// + public string DiagnosticName { get; } + + /// + public override bool Equals(object? obj) => + obj is ErrorMetadataSchemaContract other && + string.Equals(DiagnosticName, other.DiagnosticName, StringComparison.Ordinal); + + /// + public override int GetHashCode() => DiagnosticName.GetHashCode(StringComparison.Ordinal); + + private static string CreateDiagnosticName( + Func schemaFactory, + string? diagnosticName + ) + { + if (!string.IsNullOrWhiteSpace(diagnosticName)) + { + return diagnosticName; + } + + var method = schemaFactory.Method; + var methodName = method.Name; + var declaringTypeName = method.DeclaringType?.FullName ?? method.DeclaringType?.Name; + if (string.IsNullOrWhiteSpace(methodName) || methodName.Contains("<", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "A schema-based error metadata contract requires a meaningful diagnostic name. " + + "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." + ); + } + + if (!string.IsNullOrWhiteSpace(declaringTypeName) && !string.IsNullOrWhiteSpace(methodName)) + { + return $"{declaringTypeName}.{methodName}"; + } + + throw new InvalidOperationException( + "A schema-based error metadata contract requires a meaningful diagnostic name. " + + "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." + ); + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataTypeContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataTypeContract.cs new file mode 100644 index 0000000..49e9065 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataTypeContract.cs @@ -0,0 +1,31 @@ +using System; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Represents a metadata contract backed by a CLR type. +/// +public sealed class ErrorMetadataTypeContract : ErrorMetadataContract +{ + /// + /// Initializes a new instance of . + /// + /// The CLR metadata type. + public ErrorMetadataTypeContract(Type metadataType) + { + ArgumentNullException.ThrowIfNull(metadataType); + MetadataType = metadataType; + } + + /// + /// Gets the CLR metadata type. + /// + public Type MetadataType { get; } + + /// + public override bool Equals(object? obj) => + obj is ErrorMetadataTypeContract other && MetadataType == other.MetadataType; + + /// + public override int GetHashCode() => MetadataType.GetHashCode(); +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IErrorMetadataContractRegistry.cs similarity index 71% rename from src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs rename to src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IErrorMetadataContractRegistry.cs index c694615..7c58a65 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IPortableErrorMetadataContractRegistry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IErrorMetadataContractRegistry.cs @@ -5,10 +5,10 @@ namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; /// /// Provides the global map of documented error-code metadata contracts. /// -public interface IPortableErrorMetadataContractRegistry +public interface IErrorMetadataContractRegistry { /// /// Gets the immutable map of documented error codes to their metadata contracts. /// - FrozenDictionary Contracts { get; } + FrozenDictionary Contracts { get; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/NoMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/NoMetadataContract.cs new file mode 100644 index 0000000..7413013 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/NoMetadataContract.cs @@ -0,0 +1,15 @@ +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Represents a metadata contract for error codes that do not emit metadata. +/// +public sealed class NoMetadataContract : ErrorMetadataContract +{ + internal NoMetadataContract() { } + + /// + public override bool Equals(object? obj) => obj is NoMetadataContract; + + /// + public override int GetHashCode() => 0; +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs deleted file mode 100644 index bad7045..0000000 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/PortableErrorMetadataContract.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using Microsoft.OpenApi; - -namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; - -/// -/// Represents a documented metadata contract for a portable error code. -/// -public abstract class PortableErrorMetadataContract -{ - private protected PortableErrorMetadataContract() { } - - /// - /// Gets the singleton contract for error codes that do not emit metadata. - /// - public static PortableErrorMetadataContract NoMetadata { get; } = new PortableNoMetadataContract(); - - /// - /// Creates a contract backed by a CLR metadata type. - /// - /// The CLR metadata type. - /// The metadata contract. - public static PortableErrorMetadataContract FromType(Type metadataType) - { - ArgumentNullException.ThrowIfNull(metadataType); - return new PortableErrorMetadataTypeContract(metadataType); - } - - /// - /// Creates a contract backed by a schema factory. - /// - /// The factory that creates a fresh metadata schema for the requested OpenAPI version. - /// The optional diagnostic name used in duplicate-contract errors. - /// The metadata contract. - public static PortableErrorMetadataContract FromSchema( - Func schemaFactory, - [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null - ) - { - ArgumentNullException.ThrowIfNull(schemaFactory); - return new PortableErrorMetadataSchemaContract(schemaFactory, diagnosticName); - } -} - -/// -/// Represents a metadata contract backed by a CLR type. -/// -public sealed class PortableErrorMetadataTypeContract : PortableErrorMetadataContract -{ - /// - /// Initializes a new instance of . - /// - /// The CLR metadata type. - public PortableErrorMetadataTypeContract(Type metadataType) - { - ArgumentNullException.ThrowIfNull(metadataType); - MetadataType = metadataType; - } - - /// - /// Gets the CLR metadata type. - /// - public Type MetadataType { get; } - - /// - public override bool Equals(object? obj) => - obj is PortableErrorMetadataTypeContract other && MetadataType == other.MetadataType; - - /// - public override int GetHashCode() => MetadataType.GetHashCode(); -} - -/// -/// Represents a metadata contract backed by an OpenAPI schema factory. -/// -public sealed class PortableErrorMetadataSchemaContract : PortableErrorMetadataContract -{ - /// - /// Initializes a new instance of . - /// - /// The factory that creates a fresh metadata schema for the requested OpenAPI version. - /// The optional diagnostic name used in duplicate-contract errors. - public PortableErrorMetadataSchemaContract( - Func schemaFactory, - [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null - ) - { - ArgumentNullException.ThrowIfNull(schemaFactory); - SchemaFactory = schemaFactory; - DiagnosticName = CreateDiagnosticName(schemaFactory, diagnosticName); - } - - /// - /// Gets the factory that creates a fresh metadata schema for the requested OpenAPI version. - /// - public Func SchemaFactory { get; } - - /// - /// Gets the diagnostic name used in duplicate-contract errors. - /// - public string DiagnosticName { get; } - - /// - public override bool Equals(object? obj) => - obj is PortableErrorMetadataSchemaContract other && - string.Equals(DiagnosticName, other.DiagnosticName, StringComparison.Ordinal); - - /// - public override int GetHashCode() => DiagnosticName.GetHashCode(StringComparison.Ordinal); - - private static string CreateDiagnosticName( - Func schemaFactory, - string? diagnosticName - ) - { - if (!string.IsNullOrWhiteSpace(diagnosticName)) - { - return diagnosticName; - } - - var method = schemaFactory.Method; - var methodName = method.Name; - var declaringTypeName = method.DeclaringType?.FullName ?? method.DeclaringType?.Name; - if (string.IsNullOrWhiteSpace(methodName) || methodName.Contains("<", StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "A schema-based error metadata contract requires a meaningful diagnostic name. " + - "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." - ); - } - - if (!string.IsNullOrWhiteSpace(declaringTypeName) && !string.IsNullOrWhiteSpace(methodName)) - { - return $"{declaringTypeName}.{methodName}"; - } - - throw new InvalidOperationException( - "A schema-based error metadata contract requires a meaningful diagnostic name. " + - "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." - ); - } -} - -/// -/// Represents a metadata contract for error codes that do not emit metadata. -/// -public sealed class PortableNoMetadataContract : PortableErrorMetadataContract -{ - internal PortableNoMetadataContract() { } - - /// - public override bool Equals(object? obj) => obj is PortableNoMetadataContract; - - /// - public override int GetHashCode() => 0; -} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index ba74fc0..5ef29bd 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -24,7 +24,7 @@ namespace Light.PortableResults.AspNetCore.OpenApi.Generation; /// public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocumentTransformer { - private readonly IPortableErrorMetadataContractRegistry _errorMetadataContractRegistry; + private readonly IErrorMetadataContractRegistry _errorMetadataContractRegistry; private readonly PortableResultsHttpWriteOptions _writeOptions; /// @@ -32,7 +32,7 @@ public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocument /// public PortableResultsOpenApiDocumentTransformer( PortableResultsHttpWriteOptions writeOptions, - IPortableErrorMetadataContractRegistry errorMetadataContractRegistry + IErrorMetadataContractRegistry errorMetadataContractRegistry ) { _writeOptions = writeOptions ?? throw new ArgumentNullException(nameof(writeOptions)); @@ -390,7 +390,7 @@ CancellationToken cancellationToken // Collect the narrowed variants we can document for this error item schema, while tracking the // raw error codes we have already seen so we can reject conflicting metadata contracts. var documentedVariants = new List(); - var rawCodeContracts = new Dictionary(StringComparer.Ordinal); + var rawCodeContracts = new Dictionary(StringComparer.Ordinal); var inlineSanitizedCodes = new Dictionary(StringComparer.Ordinal); // Global error codes reuse pre-registered component schemas created from the application's @@ -511,7 +511,7 @@ private async Task EnsureCodeSpecificSchemaAsync( string baseSchemaId, string schemaId, string errorCode, - PortableErrorMetadataContract contract, + ErrorMetadataContract contract, OpenApiSpecVersion openApiVersion, CancellationToken cancellationToken ) @@ -588,7 +588,7 @@ OpenApiSpecVersion openApiVersion OpenApiDocument document, OpenApiDocumentTransformerContext context, string ownerSchemaId, - PortableErrorMetadataContract contract, + ErrorMetadataContract contract, OpenApiSpecVersion openApiVersion, CancellationToken cancellationToken ) @@ -596,19 +596,19 @@ CancellationToken cancellationToken var metadataSchemaId = PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(ownerSchemaId); return contract switch { - PortableErrorMetadataTypeContract typeContract => await GetStableSchemaReferenceAsync( + ErrorMetadataTypeContract typeContract => await GetStableSchemaReferenceAsync( document, context, typeContract.MetadataType, metadataSchemaId, cancellationToken ), - PortableErrorMetadataSchemaContract schemaContract => AddComponentAndCreateReference( + ErrorMetadataSchemaContract schemaContract => AddComponentAndCreateReference( document, metadataSchemaId, schemaContract.SchemaFactory(openApiVersion) ), - PortableNoMetadataContract => null, + NoMetadataContract => null, _ => throw new InvalidOperationException( $"The error metadata contract '{contract.GetType().FullName}' is not supported." ) @@ -806,9 +806,9 @@ private static void ValidateInlineMetadataArrays(PortableOpenApiErrorResponseAtt } private static void AddDocumentedCode( - IDictionary rawCodeContracts, + IDictionary rawCodeContracts, string code, - PortableErrorMetadataContract contract + ErrorMetadataContract contract ) { if (rawCodeContracts.TryGetValue(code, out var existingContract)) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index dbc4198..50b0cf7 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -6,8 +6,8 @@ internal static class PortableResultsOpenApiMessages { internal static string CreateDuplicateErrorMetadataContractMessage( string code, - PortableErrorMetadataContract existingContract, - PortableErrorMetadataContract newContract + ErrorMetadataContract existingContract, + ErrorMetadataContract newContract ) => $"The error code '{code}' is already registered with metadata contract '{DescribeContract(existingContract)}'. It cannot also be registered with '{DescribeContract(newContract)}'."; @@ -24,18 +24,18 @@ internal static string CreateUnknownErrorCodeMessage(string code) => internal static string CreateIncompleteInlineErrorMetadataMessage() => "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataContracts together."; - private static string DescribeContract(PortableErrorMetadataContract contract) + private static string DescribeContract(ErrorMetadataContract contract) { return contract switch { - PortableErrorMetadataTypeContract typeContract => typeContract.MetadataType.FullName ?? + ErrorMetadataTypeContract typeContract => typeContract.MetadataType.FullName ?? typeContract.MetadataType.Name, - PortableErrorMetadataSchemaContract schemaContract => DescribeSchemaFactory(schemaContract), - PortableNoMetadataContract => "no metadata", + ErrorMetadataSchemaContract schemaContract => DescribeSchemaFactory(schemaContract), + NoMetadataContract => "no metadata", _ => contract.GetType().FullName ?? contract.GetType().Name }; } - private static string DescribeSchemaFactory(PortableErrorMetadataSchemaContract schemaContract) + private static string DescribeSchemaFactory(ErrorMetadataSchemaContract schemaContract) => "schema factory " + schemaContract.DiagnosticName; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs index bc7d916..c0a65e1 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs @@ -42,9 +42,9 @@ internal static string[] AppendStrings(string[]? existingValues, string[] newVal return combinedValues; } - internal static PortableErrorMetadataContract[] AppendContracts( - PortableErrorMetadataContract[]? existingValues, - PortableErrorMetadataContract newValue + internal static ErrorMetadataContract[] AppendContracts( + ErrorMetadataContract[]? existingValues, + ErrorMetadataContract newValue ) { ArgumentNullException.ThrowIfNull(newValue); @@ -54,7 +54,7 @@ PortableErrorMetadataContract newValue return [newValue]; } - var combinedValues = new PortableErrorMetadataContract[existingValues.Length + 1]; + var combinedValues = new ErrorMetadataContract[existingValues.Length + 1]; Array.Copy(existingValues, combinedValues, existingValues.Length); combinedValues[^1] = newValue; return combinedValues; diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs index 8ef45e1..6246013 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs @@ -33,5 +33,5 @@ string contentType /// Gets or sets the inline metadata contracts aligned by index with /// . /// - public PortableErrorMetadataContract[]? InlineErrorMetadataContracts { get; set; } + public ErrorMetadataContract[]? InlineErrorMetadataContracts { get; set; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index 4d3ac9c..b12bf75 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -56,7 +56,7 @@ public PortableProblemOpenApiBuilder WithErrorMetadata(string code, Type metadat ); _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( _attribute.InlineErrorMetadataContracts, - PortableErrorMetadataContract.FromType(metadataType) + ErrorMetadataContract.FromType(metadataType) ); return this; } @@ -87,7 +87,7 @@ public PortableProblemOpenApiBuilder WithErrorMetadata( ); _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( _attribute.InlineErrorMetadataContracts, - PortableErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) + ErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) ); return this; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs index 7a71542..00c2f6c 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -19,7 +19,7 @@ public static class PortableResultsOpenApiModule /// public static IServiceCollection AddPortableResultsOpenApi( this IServiceCollection services, - Action? configure = null + Action? configure = null ) { ArgumentNullException.ThrowIfNull(services); @@ -28,11 +28,11 @@ public static IServiceCollection AddPortableResultsOpenApi( RegisterErrorMetadataContractRegistry(services); if (configure is not null) { - services.Configure(options => configure(options.Builder)); + services.Configure(options => configure(options.Builder)); } else { - services.AddOptions(); + services.AddOptions(); } if (services.Any(static descriptor => descriptor.ServiceType == typeof(PortableResultsOpenApiRegistrationGate))) @@ -49,10 +49,10 @@ public static IServiceCollection AddPortableResultsOpenApi( private static void RegisterErrorMetadataContractRegistry(IServiceCollection services) { - services.TryAddSingleton( + services.TryAddSingleton( static serviceProvider => - new DefaultPortableErrorMetadataContractRegistry( - serviceProvider.GetRequiredService>().Value.Builder + new DefaultErrorMetadataContractRegistry( + serviceProvider.GetRequiredService>().Value.Builder ) ); } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 18c109c..8324c9a 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -58,7 +58,7 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code, Ty ); _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( _attribute.InlineErrorMetadataContracts, - PortableErrorMetadataContract.FromType(metadataType) + ErrorMetadataContract.FromType(metadataType) ); return this; } @@ -89,7 +89,7 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata( ); _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( _attribute.InlineErrorMetadataContracts, - PortableErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) + ErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) ); return this; } diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs index f2d8790..3c13d19 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs @@ -13,8 +13,8 @@ public static class BuiltInValidationErrorContractRegistrationExtensions /// /// The error metadata contract builder. /// The configured builder. - public static PortableErrorMetadataContractsBuilder RegisterBuiltInValidationErrors( - this PortableErrorMetadataContractsBuilder builder + public static ErrorMetadataContractsBuilder RegisterBuiltInValidationErrors( + this ErrorMetadataContractsBuilder builder ) { ArgumentNullException.ThrowIfNull(builder); @@ -23,13 +23,13 @@ this PortableErrorMetadataContractsBuilder builder { switch (contract) { - case PortableErrorMetadataTypeContract typeContract: + case ErrorMetadataTypeContract typeContract: builder.ForCode(code, typeContract.MetadataType); break; - case PortableErrorMetadataSchemaContract schemaContract: + case ErrorMetadataSchemaContract schemaContract: builder.ForCode(code, schemaContract.SchemaFactory); break; - case PortableNoMetadataContract: + case NoMetadataContract: builder.ForCode(code); break; default: diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs index 5e49700..fd80e14 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -15,11 +15,11 @@ public static class BuiltInValidationErrorContracts /// /// Gets the built-in validation error metadata contracts. /// - public static FrozenDictionary Contracts { get; } = CreateContracts(); + public static FrozenDictionary Contracts { get; } = CreateContracts(); - private static FrozenDictionary CreateContracts() + private static FrozenDictionary CreateContracts() { - return new Dictionary(StringComparer.Ordinal) + return new Dictionary(StringComparer.Ordinal) { [ValidationErrorCodes.Count] = Schema( ValidationErrorCodes.Count, @@ -119,21 +119,21 @@ private static FrozenDictionary CreateCon } ) ), - [ValidationErrorCodes.NotNull] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.Null] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.NotEmpty] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.Empty] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.NotNullOrWhiteSpace] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.Email] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.DigitsOnly] = PortableErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.LettersAndDigitsOnly] = PortableErrorMetadataContract.NoMetadata + [ValidationErrorCodes.NotNull] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Null] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.NotEmpty] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Empty] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.NotNullOrWhiteSpace] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Email] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.DigitsOnly] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata }.ToFrozenDictionary(StringComparer.Ordinal); } - private static PortableErrorMetadataContract Schema( + private static ErrorMetadataContract Schema( string code, Func schemaFactory - ) => PortableErrorMetadataContract.FromSchema(schemaFactory, "built-in validation schema for " + code); + ) => ErrorMetadataContract.FromSchema(schemaFactory, "built-in validation schema for " + code); private static Func ObjectWithInteger(string propertyName) => _ => CreateObjectSchema( diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs similarity index 66% rename from tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs rename to tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs index 2773a8d..512e8eb 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs @@ -8,34 +8,34 @@ namespace Light.PortableResults.AspNetCore.OpenApi.Tests; -public sealed class PortableErrorMetadataContractTests +public sealed class ErrorMetadataContractTests { [Fact] public void ContractFactories_ShouldExposeClosedSubclassPayloads() { Func schemaFactory = _ => new OpenApiSchema(); - var typeContract = PortableErrorMetadataContract.FromType(typeof(TestMetadata)); - var schemaContract = PortableErrorMetadataContract.FromSchema(schemaFactory); - var noMetadata = PortableErrorMetadataContract.NoMetadata; + var typeContract = ErrorMetadataContract.FromType(typeof(TestMetadata)); + var schemaContract = ErrorMetadataContract.FromSchema(schemaFactory); + var noMetadata = ErrorMetadataContract.NoMetadata; - typeContract.Should().BeOfType() + typeContract.Should().BeOfType() .Which.MetadataType.Should().Be(typeof(TestMetadata)); - schemaContract.Should().BeOfType() + schemaContract.Should().BeOfType() .Which.SchemaFactory.Should().BeSameAs(schemaFactory); - ((PortableErrorMetadataSchemaContract) schemaContract).DiagnosticName.Should().Be(nameof(schemaFactory)); - noMetadata.Should().BeOfType(); - PortableErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); - typeof(PortableErrorMetadataContract) + ((ErrorMetadataSchemaContract) schemaContract).DiagnosticName.Should().Be(nameof(schemaFactory)); + noMetadata.Should().BeOfType(); + ErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); + typeof(ErrorMetadataContract) .GetMember("Kind") .Should() .BeEmpty(); var discriminator = typeContract switch { - PortableErrorMetadataTypeContract => "type", - PortableErrorMetadataSchemaContract => "schema", - PortableNoMetadataContract => "none", + ErrorMetadataTypeContract => "type", + ErrorMetadataSchemaContract => "schema", + NoMetadataContract => "none", _ => "unknown" }; discriminator.Should().Be("type"); @@ -45,7 +45,7 @@ public void ContractFactories_ShouldExposeClosedSubclassPayloads() public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() { Func schemaFactory = _ => new OpenApiSchema(); - var builder = new PortableErrorMetadataContractsBuilder(); + var builder = new ErrorMetadataContractsBuilder(); builder.ForCode("TypeCode"); builder.ForCode("TypeCode", typeof(TestMetadata)); @@ -54,25 +54,26 @@ public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() builder.ForCode("NoMetadataCode"); builder.ForCode("NoMetadataCode"); - var registry = new DefaultPortableErrorMetadataContractRegistry(builder); + var registry = new DefaultErrorMetadataContractRegistry(builder); registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode", "SchemaCode", "NoMetadataCode"); } [Fact] public void SchemaContracts_ShouldAllowExplicitDiagnosticNames() { + // ReSharper disable once ConvertToLocalFunction Func schemaFactory = _ => new OpenApiSchema(); - var schemaContract = PortableErrorMetadataContract.FromSchema(schemaFactory, "named schema"); + var schemaContract = ErrorMetadataContract.FromSchema(schemaFactory, "named schema"); - schemaContract.Should().BeOfType() + schemaContract.Should().BeOfType() .Which.DiagnosticName.Should().Be("named schema"); } [Fact] public void SchemaContracts_ShouldDeriveDiagnosticNamesFromMethodMetadata_WhenNoNameIsAvailable() { - var schemaContract = new PortableErrorMetadataSchemaContract(CreateSchema, null); + var schemaContract = new ErrorMetadataSchemaContract(CreateSchema, null); schemaContract.DiagnosticName.Should().Contain(nameof(CreateSchema)); } @@ -81,7 +82,7 @@ public void SchemaContracts_ShouldDeriveDiagnosticNamesFromMethodMetadata_WhenNo public void SchemaContracts_ShouldThrow_WhenNoMeaningfulDiagnosticNameCanBeDerived() { new Action( - () => PortableErrorMetadataContract.FromSchema( + () => ErrorMetadataContract.FromSchema( _ => new OpenApiSchema(), null ) @@ -98,7 +99,7 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() Func secondFactory = _ => new OpenApiSchema(); new Action( - () => new PortableErrorMetadataContractsBuilder() + () => new ErrorMetadataContractsBuilder() .ForCode("Conflict") .ForCode("Conflict") ) @@ -107,7 +108,7 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() .WithMessage("*Conflict*TestMetadata*OtherMetadata*"); new Action( - () => new PortableErrorMetadataContractsBuilder() + () => new ErrorMetadataContractsBuilder() .ForCode("Conflict", firstFactory) .ForCode("Conflict", secondFactory) ) @@ -116,7 +117,7 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() .WithMessage("*Conflict*firstFactory*secondFactory*"); new Action( - () => new PortableErrorMetadataContractsBuilder() + () => new ErrorMetadataContractsBuilder() .ForCode("Conflict", firstFactory, "first schema") .ForCode("Conflict", secondFactory, "second schema") ) @@ -125,7 +126,7 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() .WithMessage("*Conflict*first schema*second schema*"); new Action( - () => new PortableErrorMetadataContractsBuilder() + () => new ErrorMetadataContractsBuilder() .ForCode("Conflict") .ForCode("Conflict") ) @@ -137,20 +138,20 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() [Fact] public void ContractRegistry_ShouldExposePortableMetadataContracts() { - var registry = new DefaultPortableErrorMetadataContractRegistry( - new PortableErrorMetadataContractsBuilder().ForCode("TypeCode") + var registry = new DefaultErrorMetadataContractRegistry( + new ErrorMetadataContractsBuilder().ForCode("TypeCode") ); - registry.Contracts.Should().BeAssignableTo>(); - registry.Contracts["TypeCode"].Should().BeOfType(); + registry.Contracts.Should().BeAssignableTo>(); + registry.Contracts["TypeCode"].Should().BeOfType(); } [Fact] public void ContractRegistry_ShouldSnapshotBuilderState() { - var builder = new PortableErrorMetadataContractsBuilder().ForCode("TypeCode"); + var builder = new ErrorMetadataContractsBuilder().ForCode("TypeCode"); - var registry = new DefaultPortableErrorMetadataContractRegistry(builder); + var registry = new DefaultErrorMetadataContractRegistry(builder); builder.ForCode("OtherCode"); registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode"); @@ -159,8 +160,8 @@ public void ContractRegistry_ShouldSnapshotBuilderState() [Fact] public void ContractRegistry_ShouldRejectSanitizedCodeCollisions_WhenBuilderStateIsComposedExternally() { - var builder = new PortableErrorMetadataContractsBuilder(); - var contractsField = typeof(PortableErrorMetadataContractsBuilder).GetField( + var builder = new ErrorMetadataContractsBuilder(); + var contractsField = typeof(ErrorMetadataContractsBuilder).GetField( "_contracts", BindingFlags.Instance | BindingFlags.NonPublic ); @@ -169,24 +170,24 @@ public void ContractRegistry_ShouldRejectSanitizedCodeCollisions_WhenBuilderStat var contracts = contractsField .GetValue(builder) .Should() - .BeOfType>() + .BeOfType>() .Subject; - contracts.Add("Code/One", PortableErrorMetadataContract.NoMetadata); - contracts.Add("Code_One", PortableErrorMetadataContract.NoMetadata); + contracts.Add("Code/One", ErrorMetadataContract.NoMetadata); + contracts.Add("Code_One", ErrorMetadataContract.NoMetadata); - var act = () => _ = new DefaultPortableErrorMetadataContractRegistry(builder); + var act = () => _ = new DefaultErrorMetadataContractRegistry(builder); act.Should().Throw().WithMessage("*Code/One*Code_One*"); } [Fact] public void NoMetadataContract_ShouldReturn0_WhenGetHashCodeIsCalled() => - PortableErrorMetadataContract.NoMetadata.GetHashCode().Should().Be(0); + ErrorMetadataContract.NoMetadata.GetHashCode().Should().Be(0); [Fact] public void PortableErrorMetadataSchemaContract_ShouldReturnHashCodeFromDiagnosticName() { - var schemaContract = new PortableErrorMetadataSchemaContract(CreateSchema, null); + var schemaContract = new ErrorMetadataSchemaContract(CreateSchema, null); var hashCode = schemaContract.GetHashCode(); @@ -197,7 +198,7 @@ public void PortableErrorMetadataSchemaContract_ShouldReturnHashCodeFromDiagnost [Fact] public void PortableErrorMetadataTypeContract_ShouldReturnHashCodeFromMetadataType() { - var typeContract = new PortableErrorMetadataTypeContract(typeof(TestMetadata)); + var typeContract = new ErrorMetadataTypeContract(typeof(TestMetadata)); var hashCode = typeContract.GetHashCode(); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index f1f62d2..4f4655a 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -43,8 +43,8 @@ public void ProducesPortableProblem_ShouldAccumulateRouteMetadata() attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); attribute.InlineErrorMetadataContracts.Should().Equal( - PortableErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), - PortableErrorMetadataContract.FromType(typeof(ProblemMetadata)) + ErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), + ErrorMetadataContract.FromType(typeof(ProblemMetadata)) ); } @@ -70,8 +70,8 @@ public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); attribute.InlineErrorMetadataContracts.Should().Equal( - PortableErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), - PortableErrorMetadataContract.FromType(typeof(ProblemMetadata)) + ErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), + ErrorMetadataContract.FromType(typeof(ProblemMetadata)) ); attribute.Format.Should().Be(ValidationProblemSerializationFormat.Rich); attribute.HasFormatOverride.Should().BeTrue(); @@ -171,7 +171,7 @@ Delegate handler } private static WebApplication CreateApp( - Action configureContracts, + Action configureContracts, Action configureEndpoints ) { diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 23f5e26..df4cb76 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -206,8 +206,8 @@ public async Task Transformer_ShouldThrowWhenAnEndpointUsesAnUnknownGlobalErrorC var act = async () => await GetOpenApiDocumentAsync(app); await act.Should() - .ThrowAsync() - .WithMessage("*UnknownCode*AddPortableResultsOpenApi*WithErrorMetadata*"); + .ThrowAsync() + .WithMessage("*UnknownCode*AddPortableResultsOpenApi*WithErrorMetadata*"); } [Fact] @@ -436,11 +436,12 @@ bool configureCodes var attribute = new ProducesPortableProblemAttribute(StatusCodes.Status404NotFound); if (configureCodes) { - attribute.InlineErrorMetadataCodes = new[] { "Movie/Gone" }; + attribute.InlineErrorMetadataCodes = ["Movie/Gone"]; } else { - attribute.InlineErrorMetadataContracts = [PortableErrorMetadataContract.FromType(typeof(InlineProblemMetadata))]; + attribute.InlineErrorMetadataContracts = + [ErrorMetadataContract.FromType(typeof(InlineProblemMetadata))]; } webApplication @@ -460,7 +461,7 @@ await act.Should() [Fact] public void ErrorMetadataContractsBuilder_ShouldRejectSanitizedCodeCollisions() { - var builder = new PortableErrorMetadataContractsBuilder(); + var builder = new ErrorMetadataContractsBuilder(); builder.ForCode("Code/One"); var act = () => builder.ForCode("Code_One"); @@ -587,12 +588,12 @@ public void OpenApiModule_ShouldRegisterErrorMetadataRegistryOnlyOnce_WhenConfig contracts => contracts.ForCode("Insufficient/Funds") ); - services.Where(static descriptor => descriptor.ServiceType == typeof(IPortableErrorMetadataContractRegistry)) + services.Where(static descriptor => descriptor.ServiceType == typeof(IErrorMetadataContractRegistry)) .Should() .ContainSingle(); using var serviceProvider = services.BuildServiceProvider(); - var registry = serviceProvider.GetRequiredService(); + var registry = serviceProvider.GetRequiredService(); registry.Contracts.Should().ContainKey("VersionMismatch"); registry.Contracts.Should().ContainKey("Insufficient/Funds"); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index d1d3bed..77631ae 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -116,14 +116,14 @@ public void Contracts_ShouldContainExpectedBuiltInCodes() BuiltInValidationErrorContracts.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); foreach (var code in expectedNoMetadataCodes) { - BuiltInValidationErrorContracts.Contracts[code].Should().BeSameAs(PortableErrorMetadataContract.NoMetadata); + BuiltInValidationErrorContracts.Contracts[code].Should().BeSameAs(ErrorMetadataContract.NoMetadata); } } [Fact] public void Contracts_ShouldBeBackedByFrozenDictionary() => BuiltInValidationErrorContracts.Contracts.Should() - .BeAssignableTo>(); + .BeAssignableTo>(); [Theory] [MemberData(nameof(MetadataCodeProperties))] @@ -131,7 +131,7 @@ public void MetadataContracts_ShouldEmitExpectedObjectProperties(string code, st { var contract = BuiltInValidationErrorContracts.Contracts[code] .Should() - .BeOfType() + .BeOfType() .Subject; var firstSchema = contract.SchemaFactory(OpenApiSpecVersion.OpenApi3_1); @@ -187,10 +187,10 @@ public void PrimitiveValueSchemas_ShouldUseOpenApi30NullableParentWithoutNullBra [Fact] public void RegisterBuiltInValidationErrors_ShouldRegisterExpectedCodes() { - var builder = new PortableErrorMetadataContractsBuilder(); + var builder = new ErrorMetadataContractsBuilder(); builder.RegisterBuiltInValidationErrors(); - var registry = new DefaultPortableErrorMetadataContractRegistry(builder); + var registry = new DefaultErrorMetadataContractRegistry(builder); registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); registry.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); @@ -199,11 +199,11 @@ public void RegisterBuiltInValidationErrors_ShouldRegisterExpectedCodes() [Fact] public void RegisterBuiltInValidationErrors_ShouldBeIdempotent() { - var builder = new PortableErrorMetadataContractsBuilder(); + var builder = new ErrorMetadataContractsBuilder(); builder.RegisterBuiltInValidationErrors(); builder.RegisterBuiltInValidationErrors(); - var registry = new DefaultPortableErrorMetadataContractRegistry(builder); + var registry = new DefaultErrorMetadataContractRegistry(builder); registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); } @@ -211,7 +211,7 @@ public void RegisterBuiltInValidationErrors_ShouldBeIdempotent() [Fact] public void RegisterBuiltInValidationErrors_ShouldRejectConflictingPreRegisteredContracts() { - var builder = new PortableErrorMetadataContractsBuilder() + var builder = new ErrorMetadataContractsBuilder() .ForCode(ValidationErrorCodes.Count); var act = builder.RegisterBuiltInValidationErrors; @@ -221,7 +221,7 @@ public void RegisterBuiltInValidationErrors_ShouldRejectConflictingPreRegistered private static OpenApiSchema GetFirstPrimitiveValueSchema(string code, OpenApiSpecVersion version) { - var contract = (PortableErrorMetadataSchemaContract) BuiltInValidationErrorContracts.Contracts[code]; + var contract = (ErrorMetadataSchemaContract) BuiltInValidationErrorContracts.Contracts[code]; var schema = contract.SchemaFactory(version); var propertyName = schema.Properties!.ContainsKey(ValidationErrorMetadataKeys.ComparativeValue) ? ValidationErrorMetadataKeys.ComparativeValue : @@ -232,6 +232,7 @@ private static OpenApiSchema GetFirstPrimitiveValueSchema(string code, OpenApiSp // ReSharper disable once ClassNeverInstantiated.Local -- required for testing private sealed class ConflictingMetadata { + // ReSharper disable once UnusedMember.Local -- required for testing public int Value { get; init; } } } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs index 60dad74..79fdec3 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs @@ -20,7 +20,7 @@ namespace Light.PortableResults.Validation.OpenApi.Tests; internal static class ValidationOpenApiDocumentTestUtilities { internal static WebApplication CreateApp( - Action configureContracts, + Action configureContracts, Action configureEndpoints, Action? configureOpenApi = null ) From 9fabd9ac976567e644d49ecab759a7f0a8bae0b5 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 10:09:40 +0200 Subject: [PATCH 53/67] chore: fix formatting when registering endpoints Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs | 3 +-- samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs | 3 +-- .../NewMovieRating/NewMovieRatingEndpoint.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index d4115a1..2584f6a 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -18,8 +18,7 @@ namespace NativeAotMovieRating.GetMovies; public static class GetMoviesEndpoint { public static void MapGetMoviesEndpoint(this WebApplication app) => - app - .MapGet("/api/movies", GetMovies) + app.MapGet("/api/movies", GetMovies) .WithName("GetMovies") .WithTags("Movies") .WithSummary("Returns a paginated list of movies.") diff --git a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs index 13b5a0c..4c98e02 100644 --- a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs @@ -12,8 +12,7 @@ namespace NativeAotMovieRating.NewMovie; public static class AddNewMovieEndpoint { public static void MapNewMovieEndpoint(this WebApplication app) => - app - .MapPut("/api/movies", NewMovieRating) + app.MapPut("/api/movies", NewMovieRating) .WithName("AddNewMovie") .WithTags("Movies") .WithSummary("Adds a new movie.") diff --git a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs index 8b3a7a9..ee17cb5 100644 --- a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs @@ -14,8 +14,7 @@ namespace NativeAotMovieRating.NewMovieRating; public static class NewMovieRatingEndpoint { public static void MapAddMovieRatingEndpoint(this WebApplication app) => - app - .MapPut("/api/moviesRatings", AddMovieRating) + app.MapPut("/api/moviesRatings", AddMovieRating) .WithName("AddMovieRating") .WithTags("Movie Ratings") .WithSummary("Adds or updates a movie rating.") From fdc277a49dc726378eb33d435921f94e06a3019c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 15:30:42 +0200 Subject: [PATCH 54/67] chore: introduce Swashbuckle UI in NativeAotMovieRating.csproj Signed-off-by: Kenny Pflug --- Directory.Packages.props | 3 ++- .../NativeAotMovieRating.csproj | 1 + .../NativeAotMovieRating/OpenApi/OpenApiModule.cs | 6 ++++++ samples/NativeAotMovieRating/packages.lock.json | 14 ++++++++++---- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 03f2b11..91e322f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + @@ -29,4 +30,4 @@ - \ No newline at end of file + diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index 76f0450..b6b8c05 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -22,6 +22,7 @@ + diff --git a/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs b/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs index b969a85..7838c23 100644 --- a/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs +++ b/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs @@ -10,6 +10,12 @@ public static void MapOpenApiAndScalar(this WebApplication app) { app.MapOpenApi(); app.MapScalarApiReference("/docs", options => options.WithTitle("Native AOT Movie Rating API")); + app.UseSwaggerUI(options => + { + options.DocumentTitle = "Native AOT Movie Rating API - Swagger UI"; + options.RoutePrefix = "swagger"; + options.SwaggerEndpoint("/openapi/v1.json", "Native AOT Movie Rating API v1"); + }); } public static void RedirectHomeToDocs(this WebApplication app) => diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 4ecf302..cf9a6a0 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -53,6 +53,12 @@ "Serilog.Sinks.File": "7.0.0" } }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Direct", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" + }, "Light.GuardClauses": { "type": "Transitive", "resolved": "13.0.0", @@ -183,20 +189,20 @@ "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } }, - "net10.0/linux-x64": { + "net10.0/osx-arm64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", "requested": "[10.0.7, )", "resolved": "10.0.7", "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==", "dependencies": { - "runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.7" + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.7" } }, - "runtime.linux-x64.Microsoft.DotNet.ILCompiler": { + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", "resolved": "10.0.7", - "contentHash": "bz+Di9NJXvaWTvoma5Pf9JrgFj6MGkbPo9dlWRo+jOHXDEme511jeWVEBWoPdoDe6BjDWRngGi9P9EUBCCgzgw==" + "contentHash": "ycFCaZwEvd0nNqcW53l0KWM+fz74owXpWj5C/z0GjznwAtHwmGTeh3vGTGFrXD9LEagX8G3cHRtzGDrTabIrwQ==" } } } From f6f9518b90087d808b2ce50098760d8613629e3e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 19:20:47 +0200 Subject: [PATCH 55/67] chore: add plan 0040-5 for exhaustive exhaustive error-code schemas Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0040-5-openapi-exhaustive.md | 114 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 ai-plans/0040-5-openapi-exhaustive.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 53448b2..7fe6fc5 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -54,6 +54,7 @@ + diff --git a/ai-plans/0040-5-openapi-exhaustive.md b/ai-plans/0040-5-openapi-exhaustive.md new file mode 100644 index 0000000..60e91b2 --- /dev/null +++ b/ai-plans/0040-5-openapi-exhaustive.md @@ -0,0 +1,114 @@ +# Exhaustive Error-Code Schemas and Flattened Envelopes + +## Rationale + +Plans `0040-1`, `0040-2`, and `0040-4` produce a working OpenAPI surface, but two design choices weaken the generated document for downstream consumers (Swagger UI, Scalar, Kiota, NSwag, openapi-generator): + +1. **Documented error codes are not exhaustive by default.** `CreateDocumentedErrorItemSchemaAsync` in `PortableResultsOpenApiDocumentTransformer` always appends a `$ref` to the canonical `PortableError` / `PortableValidationErrorDetail` schema as a fallback branch and narrows with `anyOf` instead of `oneOf`. The fallback exists so undocumented codes still validate, but it has three undesirable side effects: (a) the discriminator is never load-bearing because the fallback also matches, (b) client generators that key off discriminated unions cannot produce sealed type hierarchies, and (c) endpoints that genuinely declare their full error contract via `WithErrorCodes(...)` / `With*Error()` are documented as if they could still emit anything. Pre-stable is the right moment to flip the default to exhaustive and offer a small explicit opt-out. + +2. **The derived envelope is an `allOf` composition rather than a concrete object schema.** `CreateErrorResponseSchemaAsync` emits `allOf: [ $ref(canonical), extensionWithErrorsAndMetadataOverrides ]`. Several mainstream codegen tools (NSwag, openapi-generator) handle multi-level `allOf` chains awkwardly, producing intermediate base classes that do not reflect the real wire shape. Flattening *the outer envelope only* — copying the canonical properties and overriding `errors` / `errorDetails` / `metadata` — produces a schema that renders cleanly in Swagger UI / Scalar and codegens to a single concrete type per response slot. + +This plan does **not** flatten the per-code error variants (`PortableError__InRange`, etc.). The `allOf [base, { code: const, metadata: $ref }]` shape is the canonical JSON Schema idiom for discriminated narrowing; mainstream tools render it well, and Kiota in particular relies on the structural relationship for discriminator subtyping. Flattening per-code variants would also multiply duplication (~25 built-in codes × every endpoint that opts in) without a corresponding rendering win. + +OpenAPI support has not shipped, so this is the right time to make these changes as a coordinated, breaking schema-design correction. Validator-driven endpoint generation, source generators, and example synthesis are explicitly out of scope. + +## Acceptance Criteria + +- [ ] The transformer's documented-error item schema is exhaustive by default. When `CreateDocumentedErrorItemSchemaAsync` produces a discriminated item schema, it emits `oneOf` (not `anyOf`) over the documented variants and does **not** append a fallback `$ref` to the canonical `PortableError` / `PortableValidationErrorDetail` schema. The discriminator mapping is unchanged. +- [ ] An opt-in escape hatch `AllowUnknownErrorCodes()` is added to both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`. When the flag is set on the response attribute, the transformer reverts to the previous behavior: `anyOf` over the documented variants plus the canonical fallback `$ref`. The discriminator is preserved in both modes; in the non-exhaustive mode the documented mapping still narrows the documented codes and the fallback covers the rest. +- [ ] A boolean `bool AllowUnknownErrorCodes { get; set; }` property is added to `PortableOpenApiErrorResponseAttributeBase`. It is the single source of truth that the transformer reads, so MVC consumers can set it directly on `[ProducesPortableProblem]` / `[ProducesPortableValidationProblem]` and Minimal API consumers go through the builder helper. The default value is `false` (exhaustive). +- [ ] In exhaustive mode the narrowed item schema requires a non-null `code`: the wrapper-level `Required` set continues to include `"code"`, every per-code variant requires `"code"` (already the behavior of `CreateCodeSpecificSchema`), and the schema is asserted to be honest about this contract via a regression test. Documenting an endpoint with `WithErrorCodes(...)` is therefore a developer-asserted guarantee that every emitted error item carries a `code` and that code is in the documented set; if either half can be violated, the correct response is `AllowUnknownErrorCodes()`. +- [ ] When `WithErrorCodes` / inline `WithErrorMetadata` / `With*Error()` are not called *and* `AllowUnknownErrorCodes()` is also not called, the response continues to reference the canonical envelope component (no narrowed item schema is synthesized). This preserves the current behavior of an undecorated `ProducesPortableProblem(...)` and means exhaustive-by-default only applies once the endpoint has documented at least one code. +- [ ] The derived error response envelope is flattened. `CreateErrorResponseSchemaAsync` produces a concrete `OpenApiSchema` with `Type = JsonSchemaType.Object`, the canonical properties copied directly, and `errors` / `errorDetails` / `metadata` overridden with the narrowed shapes. The result no longer uses `AllOf` to compose against the canonical base. The component id naming (`______`) is unchanged. +- [ ] Per-code error item variants (`PortableError__`, `PortableValidationErrorDetail__`, and their inline endpoint-scoped counterparts) continue to use `allOf [base, { code: const, metadata: $ref }]`. `CreateCodeSpecificSchema` is not changed by this plan, and tests assert the per-code variant shape is preserved verbatim. +- [ ] The success response envelope synthesized by `CreateSuccessResponseSchemaAsync` is unchanged structurally: it already produces a concrete object schema with `value` and (optionally) `metadata`, so no flattening work is needed there. +- [ ] `PortableResultsOpenApiSchemas` exposes exactly two public helpers that return *fresh* property dictionaries for the canonical error envelopes: `CreatePortableProblemDetailsProperties(OpenApiDocument document)` (used for both `PortableProblemDetails` and `PortableRichValidationProblemDetails`, which currently share a property set) and `CreatePortableAspNetCoreValidationProblemDetailsProperties(OpenApiDocument document)`. These helpers replace the existing private `CreateProblemDetailsProperties` and are the single source of truth used by both `InstallInto` (for the canonical components) and the transformer (for the flattened derived envelopes), so the canonical and derived schemas can never structurally drift. +- [ ] Both modes preserve discriminator mapping coverage. In exhaustive mode the `discriminator.mapping` keys are exactly the documented raw codes. In non-exhaustive mode the mapping is unchanged from today (documented codes only; the fallback branch carries no mapping entry). Documented mapping values continue to use JSON-Pointer-escaped `$ref`s as established in `0040-1`. +- [ ] The `NativeAotMovieRating` sample is updated to the new exhaustive-by-default behavior. Endpoints that already enumerate their full error contract gain no new code; endpoints (if any) that genuinely emit unknown codes call `.AllowUnknownErrorCodes()` so the produced document remains accurate. +- [ ] Automated tests cover: + - The exhaustive default: `WithErrorCodes(...)` produces a `oneOf` with no fallback `$ref`, and the discriminator mapping enumerates exactly the documented codes. + - Inline-only narrowing in exhaustive mode: an endpoint that calls only `.WithInRangeError()` (no `WithErrorCodes`) produces a single-branch `oneOf` with the inline endpoint-scoped variant and no fallback `$ref`. + - Metadata-only narrowing: an endpoint that calls only `.WithMetadata()` (no error narrowing, no `AllowUnknownErrorCodes()`) produces a flattened envelope with `metadata` overridden by the narrowed reference and `errors` / `errorDetails` still pointing at the canonical item schema. + - The opt-out: `WithErrorCodes(...).AllowUnknownErrorCodes()` produces the previous `anyOf + fallback` shape. + - Mixed global + inline narrowing in exhaustive mode (`WithErrorCodes(...).WithInRangeError()`) continues to produce one `oneOf` branch per documented code with no fallback. + - The undecorated case (no `WithErrorCodes`, no inline narrowing, no `AllowUnknownErrorCodes()`) still yields a plain `$ref` to the canonical envelope, not a degenerate `oneOf` over an empty set. + - The narrowed-item `code`-required contract: the wrapper-level `Required` set contains `"code"` and every per-code variant requires `"code"` in both exhaustive and non-exhaustive modes. + - The flattened outer envelope: the derived response component is a concrete object schema with the canonical properties copied verbatim, `errors` / `errorDetails` overridden with the narrowed array, and `metadata` overridden when `WithMetadata()` is used. `AllOf` is absent at the outer envelope level. + - The per-code error variants (`PortableError__`, inline endpoint-scoped variants) still use `allOf [base, extension]` — explicit regression coverage so the non-flattening of per-code variants cannot be silently changed. + - A full document-validation pass continues to produce a spec-valid OpenAPI 3.0 and 3.1 document under the new schemas. +- [ ] `README.md` is updated to describe the exhaustive-by-default behavior of `WithErrorCodes` / `With*Error()`, the `AllowUnknownErrorCodes()` opt-out, the contract that documented endpoints assert every emitted error carries a known `code`, and the flattened derived envelope shape. Any prose that previously described the fallback `$ref` as inevitable is removed or qualified. + +## Technical Details + +### Exhaustive-by-Default Discriminated Union + +The change is local to `CreateDocumentedErrorItemSchemaAsync` in `PortableResultsOpenApiDocumentTransformer`. After collecting `documentedVariants`, the method currently appends a fallback `$ref` to `itemBaseSchemaId` and emits `anyOf` over (documented variants + fallback). The new logic reads `attribute.AllowUnknownErrorCodes`: + +- **Exhaustive (default).** Emit `OneOf = documentedVariants.Select(v => v.SchemaReference).ToList()`, no fallback. The discriminator mapping is unchanged. `oneOf` is semantically correct here because each narrowed variant is an `allOf` restriction of the base schema, and without the base in the union no two branches can both match for a given concrete error item — the `code` `const` (or `enum` for OpenAPI 3.0) ensures exactly one branch matches. + +- **Non-exhaustive (opt-in).** Emit the existing shape: `anyOf` over (documented variants + fallback `$ref` to `itemBaseSchemaId`). `oneOf` is *not* correct here because the fallback `$ref` to the canonical base also matches the documented codes (every narrowed variant is an `allOf` restriction of the base), so two branches would match and `oneOf` validation would fail. The discriminator mapping carries only the documented codes, as today. + +The "no documented variants and no opt-out" branch (the early `return null;` path that today produces an unwrapped `$ref` to the canonical envelope) is preserved verbatim. Exhaustive-by-default only applies once at least one code has been documented. + +#### The Code-Required Contract + +Exhaustive `oneOf` correctness depends on every emitted error item carrying a non-null `code`. The narrowed item schema enforces this at validation time on two levels: the wrapper-level `Required` set contains `"code"` (already true today via `CreateDocumentedErrorItemSchemaAsync`), and every per-code variant requires `"code"` (already true via `CreateCodeSpecificSchema`). The canonical `PortableError` / `PortableValidationErrorDetail` schemas leave `code` nullable and not required so that the *un-narrowed* envelope can still describe error items that omit a code; once an endpoint opts into narrowing, the narrowed schema tightens the contract. + +This means calling `WithErrorCodes(...)` is a developer-asserted contract that every error this endpoint emits carries a `code` and that code is in the documented set. If either half can be violated — code-less errors, third-party error propagation, defensive `Error.Internal(...)` paths whose codes are not enumerable up-front — the correct response is `AllowUnknownErrorCodes()`. The plan does not modify the runtime to enforce code presence on errors emitted from documented endpoints; that would be a separate `Light.PortableResults` runtime change and is out of scope here. + +### `AllowUnknownErrorCodes()` Builder Method + +Both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder` gain a one-line method that flips the new attribute property to `true`. The method returns `this` for chaining and is idempotent. Because the property lives on `PortableOpenApiErrorResponseAttributeBase`, MVC consumers can set it via attribute property syntax (`[ProducesPortableProblem(StatusCode = 400, AllowUnknownErrorCodes = true)]`) without going through the builders. + +### Flattened Outer Envelope + +`CreateErrorResponseSchemaAsync` currently builds: + +``` +new OpenApiSchema { + AllOf = [ CreateSchemaReference(document, canonicalSchemaId), extensionSchema ] +} +``` + +where `extensionSchema` carries the optional `metadata` override and the `errors`/`errorDetails` array. The new shape is a single concrete object schema: + +``` +new OpenApiSchema { + Type = JsonSchemaType.Object, + Properties = , + Required = +} +``` + +To avoid duplicating the canonical property definitions, `PortableResultsOpenApiSchemas` exposes exactly two new public helpers that return fresh property dictionaries: `CreatePortableProblemDetailsProperties(OpenApiDocument document)` and `CreatePortableAspNetCoreValidationProblemDetailsProperties(OpenApiDocument document)`. The first is used by both `PortableProblemDetails` and `PortableRichValidationProblemDetails` because they currently share an identical property set; the second is used by `PortableAspNetCoreValidationProblemDetails`, which carries the additional `errorDetails` slot. The existing private `CreateProblemDetailsProperties` is removed in favor of these. `InstallInto` calls the same helpers when authoring the canonical components, so canonical and derived schemas are guaranteed to stay structurally aligned. The transformer dispatches on the canonical schema id resolved by `ResolveCanonicalErrorEnvelopeSchemaId` to pick the right helper. + +The transformer's flatten path: + +1. Resolves the canonical schema id (`ResolveCanonicalErrorEnvelopeSchemaId`, unchanged). +2. Calls the matching property-dictionary helper to obtain a fresh `Dictionary` keyed ordinally. +3. If `attribute.TopLevelMetadataType is not null`, replaces `properties["metadata"]` with the narrowed metadata reference (built the same way as today via `GetStableSchemaReferenceAsync` under the existing metadata-component naming). +4. If `documentedErrorSchema` is not null, replaces `properties["errors"]` (or `properties["errorDetails"]` for the ASP.NET-Core-compatible format) with the narrowed array. The branch picking `errors` vs `errorDetails` is identical to today. +5. Builds the flattened concrete schema and registers it under the existing `derivedEnvelopeSchemaId`. + +The `errors`-vs-`errorDetails` selector remains keyed on the canonical schema id, so the `PortableAspNetCoreValidationProblemDetails` envelope continues to use `errorDetails` while both rich envelopes use `errors`. + +### Per-Code Variants Stay as `allOf` + +`CreateCodeSpecificSchema` is intentionally untouched. It continues to produce: + +``` +allOf: + - $ref: '#/components/schemas/PortableError' + - { type: object, properties: { code: const | enum, metadata: $ref? }, required: [code] } +``` + +This is the canonical JSON Schema idiom for discriminated narrowing and is consumed correctly by Swagger UI, Scalar, and Kiota's discriminator subtype detection. A regression test asserts the shape so a future contributor cannot quietly flatten it. + +### Scope Boundaries + +- This plan does not change the canonical schema catalog (`PortableError`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `ErrorCategory`). Their public component ids and shapes are preserved. +- This plan does not change the per-code error variant shape (`PortableError__`, `PortableValidationErrorDetail__`, inline endpoint-scoped variants). +- This plan does not introduce validator-driven endpoint generation, source-generator code paths, automatic example emission, or any new automation that infers documented error codes from validator implementations. Those remain candidates for separate, larger plans. +- This plan does not unify exhaustive/non-exhaustive into a single shape. The opt-out exists precisely because some endpoints genuinely emit unknown codes (third-party error propagation, defensive `Error.Internal(...)` paths) and weakening the discriminator only for those endpoints is the honest documentation choice. +- This plan does not modify the `Light.PortableResults` runtime to enforce that errors emitted from documented endpoints carry a non-null `code`. Exhaustive mode is a developer-asserted contract enforced at JSON Schema validation time on the consumer side, not at runtime by the producer. Tightening the runtime side is a separate plan. +- This plan does not touch runtime serialization. The wire format produced by `LightResult`, `LightActionResult`, and the JSON writers is unchanged; only the generated OpenAPI document is affected. From b2922a77a9a17ed73a85e8adfb1b9c1ecdf05d1a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 19:52:03 +0200 Subject: [PATCH 56/67] feat: introduce exhaustive OpenAPI error code schemas Signed-off-by: Kenny Pflug --- README.md | 23 +- ai-plans/0040-5-openapi-exhaustive.md | 34 +-- .../GetMovies/GetMoviesEndpoint.cs | 10 +- ...rtableResultsOpenApiDocumentTransformer.cs | 93 ++++++-- ...rtableOpenApiErrorResponseAttributeBase.cs | 5 + .../PortableProblemOpenApiBuilder.cs | 9 + ...PortableValidationProblemOpenApiBuilder.cs | 9 + .../Schemas/PortableResultsOpenApiSchemas.cs | 91 +++++--- .../PortableOpenApiResponseBuilderTests.cs | 4 + ...eResultsOpenApiDocumentTransformerTests.cs | 219 ++++++++++++++++-- .../PortableResultsOpenApiSchemasTests.cs | 42 ++++ .../PortableProblemOpenApiBuilderTests.cs | 9 +- ...bleValidationProblemOpenApiBuilderTests.cs | 19 +- .../ValidationOpenApiDocumentTestUtilities.cs | 5 +- 14 files changed, 466 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 084e557..18a69e4 100644 --- a/README.md +++ b/README.md @@ -1382,6 +1382,14 @@ PortableResults OpenAPI metadata is authoritative for a given `(status code, con 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(code)`, and the typed validation helpers such as `WithInRangeError()` 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 @@ -1408,6 +1416,19 @@ app.MapPut("/api/movieRatings", AddMovieRating) ); ``` +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() + .AllowUnknownErrorCodes() + ); +``` + Comparison and range codes are polymorphic at the global code level, so the validation bridge also ships typed endpoint helpers: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. 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(...)`: @@ -1482,7 +1503,7 @@ public sealed class AddMovieRatingsController(AddMovieRatingService service) : C statusCode: StatusCodes.Status404NotFound, TopLevelMetadataType = typeof(MovieProblemMetadata), InlineErrorMetadataCodes = new[] { "MovieNotFound" }, - InlineErrorMetadataTypes = new[] { typeof(MovieNotFoundMetadata) } + InlineErrorMetadataContracts = new[] { ErrorMetadataContract.FromType(typeof(MovieNotFoundMetadata)) } )] [ProducesPortableProblem] public async Task> AddMovieRating(AddMovieRatingDto dto) diff --git a/ai-plans/0040-5-openapi-exhaustive.md b/ai-plans/0040-5-openapi-exhaustive.md index 60e91b2..5e4acad 100644 --- a/ai-plans/0040-5-openapi-exhaustive.md +++ b/ai-plans/0040-5-openapi-exhaustive.md @@ -1,5 +1,9 @@ # Exhaustive Error-Code Schemas and Flattened Envelopes +## Update 2026-04-30 + +Implementation deviates slightly from the original plan to remove the remaining structural-drift risk between canonical and derived error envelopes. In addition to the planned public property helpers, `PortableResultsOpenApiSchemas` also exposes public helpers for the canonical `Required` sets, and both `InstallInto` and the transformer now consume those shared helpers. + ## Rationale Plans `0040-1`, `0040-2`, and `0040-4` produce a working OpenAPI surface, but two design choices weaken the generated document for downstream consumers (Swagger UI, Scalar, Kiota, NSwag, openapi-generator): @@ -14,18 +18,18 @@ OpenAPI support has not shipped, so this is the right time to make these changes ## Acceptance Criteria -- [ ] The transformer's documented-error item schema is exhaustive by default. When `CreateDocumentedErrorItemSchemaAsync` produces a discriminated item schema, it emits `oneOf` (not `anyOf`) over the documented variants and does **not** append a fallback `$ref` to the canonical `PortableError` / `PortableValidationErrorDetail` schema. The discriminator mapping is unchanged. -- [ ] An opt-in escape hatch `AllowUnknownErrorCodes()` is added to both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`. When the flag is set on the response attribute, the transformer reverts to the previous behavior: `anyOf` over the documented variants plus the canonical fallback `$ref`. The discriminator is preserved in both modes; in the non-exhaustive mode the documented mapping still narrows the documented codes and the fallback covers the rest. -- [ ] A boolean `bool AllowUnknownErrorCodes { get; set; }` property is added to `PortableOpenApiErrorResponseAttributeBase`. It is the single source of truth that the transformer reads, so MVC consumers can set it directly on `[ProducesPortableProblem]` / `[ProducesPortableValidationProblem]` and Minimal API consumers go through the builder helper. The default value is `false` (exhaustive). -- [ ] In exhaustive mode the narrowed item schema requires a non-null `code`: the wrapper-level `Required` set continues to include `"code"`, every per-code variant requires `"code"` (already the behavior of `CreateCodeSpecificSchema`), and the schema is asserted to be honest about this contract via a regression test. Documenting an endpoint with `WithErrorCodes(...)` is therefore a developer-asserted guarantee that every emitted error item carries a `code` and that code is in the documented set; if either half can be violated, the correct response is `AllowUnknownErrorCodes()`. -- [ ] When `WithErrorCodes` / inline `WithErrorMetadata` / `With*Error()` are not called *and* `AllowUnknownErrorCodes()` is also not called, the response continues to reference the canonical envelope component (no narrowed item schema is synthesized). This preserves the current behavior of an undecorated `ProducesPortableProblem(...)` and means exhaustive-by-default only applies once the endpoint has documented at least one code. -- [ ] The derived error response envelope is flattened. `CreateErrorResponseSchemaAsync` produces a concrete `OpenApiSchema` with `Type = JsonSchemaType.Object`, the canonical properties copied directly, and `errors` / `errorDetails` / `metadata` overridden with the narrowed shapes. The result no longer uses `AllOf` to compose against the canonical base. The component id naming (`______`) is unchanged. -- [ ] Per-code error item variants (`PortableError__`, `PortableValidationErrorDetail__`, and their inline endpoint-scoped counterparts) continue to use `allOf [base, { code: const, metadata: $ref }]`. `CreateCodeSpecificSchema` is not changed by this plan, and tests assert the per-code variant shape is preserved verbatim. -- [ ] The success response envelope synthesized by `CreateSuccessResponseSchemaAsync` is unchanged structurally: it already produces a concrete object schema with `value` and (optionally) `metadata`, so no flattening work is needed there. -- [ ] `PortableResultsOpenApiSchemas` exposes exactly two public helpers that return *fresh* property dictionaries for the canonical error envelopes: `CreatePortableProblemDetailsProperties(OpenApiDocument document)` (used for both `PortableProblemDetails` and `PortableRichValidationProblemDetails`, which currently share a property set) and `CreatePortableAspNetCoreValidationProblemDetailsProperties(OpenApiDocument document)`. These helpers replace the existing private `CreateProblemDetailsProperties` and are the single source of truth used by both `InstallInto` (for the canonical components) and the transformer (for the flattened derived envelopes), so the canonical and derived schemas can never structurally drift. -- [ ] Both modes preserve discriminator mapping coverage. In exhaustive mode the `discriminator.mapping` keys are exactly the documented raw codes. In non-exhaustive mode the mapping is unchanged from today (documented codes only; the fallback branch carries no mapping entry). Documented mapping values continue to use JSON-Pointer-escaped `$ref`s as established in `0040-1`. -- [ ] The `NativeAotMovieRating` sample is updated to the new exhaustive-by-default behavior. Endpoints that already enumerate their full error contract gain no new code; endpoints (if any) that genuinely emit unknown codes call `.AllowUnknownErrorCodes()` so the produced document remains accurate. -- [ ] Automated tests cover: +- [x] The transformer's documented-error item schema is exhaustive by default. When `CreateDocumentedErrorItemSchemaAsync` produces a discriminated item schema, it emits `oneOf` (not `anyOf`) over the documented variants and does **not** append a fallback `$ref` to the canonical `PortableError` / `PortableValidationErrorDetail` schema. The discriminator mapping is unchanged. +- [x] An opt-in escape hatch `AllowUnknownErrorCodes()` is added to both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`. When the flag is set on the response attribute, the transformer reverts to the previous behavior: `anyOf` over the documented variants plus the canonical fallback `$ref`. The discriminator is preserved in both modes; in the non-exhaustive mode the documented mapping still narrows the documented codes and the fallback covers the rest. +- [x] A boolean `bool AllowUnknownErrorCodes { get; set; }` property is added to `PortableOpenApiErrorResponseAttributeBase`. It is the single source of truth that the transformer reads, so MVC consumers can set it directly on `[ProducesPortableProblem]` / `[ProducesPortableValidationProblem]` and Minimal API consumers go through the builder helper. The default value is `false` (exhaustive). +- [x] In exhaustive mode the narrowed item schema requires a non-null `code`: the wrapper-level `Required` set continues to include `"code"`, every per-code variant requires `"code"` (already the behavior of `CreateCodeSpecificSchema`), and the schema is asserted to be honest about this contract via a regression test. Documenting an endpoint with `WithErrorCodes(...)` is therefore a developer-asserted guarantee that every emitted error item carries a `code` and that code is in the documented set; if either half can be violated, the correct response is `AllowUnknownErrorCodes()`. +- [x] When `WithErrorCodes` / inline `WithErrorMetadata` / `With*Error()` are not called *and* `AllowUnknownErrorCodes()` is also not called, the response continues to reference the canonical envelope component (no narrowed item schema is synthesized). This preserves the current behavior of an undecorated `ProducesPortableProblem(...)` and means exhaustive-by-default only applies once the endpoint has documented at least one code. +- [x] The derived error response envelope is flattened. `CreateErrorResponseSchemaAsync` produces a concrete `OpenApiSchema` with `Type = JsonSchemaType.Object`, the canonical properties copied directly, and `errors` / `errorDetails` / `metadata` overridden with the narrowed shapes. The result no longer uses `AllOf` to compose against the canonical base. The component id naming (`______`) is unchanged. +- [x] Per-code error item variants (`PortableError__`, `PortableValidationErrorDetail__`, and their inline endpoint-scoped counterparts) continue to use `allOf [base, { code: const, metadata: $ref }]`. `CreateCodeSpecificSchema` is not changed by this plan, and tests assert the per-code variant shape is preserved verbatim. +- [x] The success response envelope synthesized by `CreateSuccessResponseSchemaAsync` is unchanged structurally: it already produces a concrete object schema with `value` and (optionally) `metadata`, so no flattening work is needed there. +- [x] `PortableResultsOpenApiSchemas` exposes shared public helpers that return *fresh* property dictionaries and required sets for the canonical error envelopes: `CreatePortableProblemDetailsProperties(OpenApiDocument document)`, `CreatePortableProblemDetailsRequired()`, `CreatePortableAspNetCoreValidationProblemDetailsProperties(OpenApiDocument document)`, and `CreatePortableAspNetCoreValidationProblemDetailsRequired()`. These helpers replace the existing private `CreateProblemDetailsProperties` and are the single source of truth used by both `InstallInto` (for the canonical components) and the transformer (for the flattened derived envelopes), so the canonical and derived schemas can never structurally drift. +- [x] Both modes preserve discriminator mapping coverage. In exhaustive mode the `discriminator.mapping` keys are exactly the documented raw codes. In non-exhaustive mode the mapping is unchanged from today (documented codes only; the fallback branch carries no mapping entry). Documented mapping values continue to use JSON-Pointer-escaped `$ref`s as established in `0040-1`. +- [x] The `NativeAotMovieRating` sample is updated to the new exhaustive-by-default behavior. Endpoints that already enumerate their full error contract gain no new code; endpoints (if any) that genuinely emit unknown codes call `.AllowUnknownErrorCodes()` so the produced document remains accurate. +- [x] Automated tests cover: - The exhaustive default: `WithErrorCodes(...)` produces a `oneOf` with no fallback `$ref`, and the discriminator mapping enumerates exactly the documented codes. - Inline-only narrowing in exhaustive mode: an endpoint that calls only `.WithInRangeError()` (no `WithErrorCodes`) produces a single-branch `oneOf` with the inline endpoint-scoped variant and no fallback `$ref`. - Metadata-only narrowing: an endpoint that calls only `.WithMetadata()` (no error narrowing, no `AllowUnknownErrorCodes()`) produces a flattened envelope with `metadata` overridden by the narrowed reference and `errors` / `errorDetails` still pointing at the canonical item schema. @@ -36,7 +40,7 @@ OpenAPI support has not shipped, so this is the right time to make these changes - The flattened outer envelope: the derived response component is a concrete object schema with the canonical properties copied verbatim, `errors` / `errorDetails` overridden with the narrowed array, and `metadata` overridden when `WithMetadata()` is used. `AllOf` is absent at the outer envelope level. - The per-code error variants (`PortableError__`, inline endpoint-scoped variants) still use `allOf [base, extension]` — explicit regression coverage so the non-flattening of per-code variants cannot be silently changed. - A full document-validation pass continues to produce a spec-valid OpenAPI 3.0 and 3.1 document under the new schemas. -- [ ] `README.md` is updated to describe the exhaustive-by-default behavior of `WithErrorCodes` / `With*Error()`, the `AllowUnknownErrorCodes()` opt-out, the contract that documented endpoints assert every emitted error carries a known `code`, and the flattened derived envelope shape. Any prose that previously described the fallback `$ref` as inevitable is removed or qualified. +- [x] `README.md` is updated to describe the exhaustive-by-default behavior of `WithErrorCodes` / `With*Error()`, the `AllowUnknownErrorCodes()` opt-out, the contract that documented endpoints assert every emitted error carries a known `code`, and the flattened derived envelope shape. Any prose that previously described the fallback `$ref` as inevitable is removed or qualified. ## Technical Details @@ -54,7 +58,7 @@ The "no documented variants and no opt-out" branch (the early `return null;` pat Exhaustive `oneOf` correctness depends on every emitted error item carrying a non-null `code`. The narrowed item schema enforces this at validation time on two levels: the wrapper-level `Required` set contains `"code"` (already true today via `CreateDocumentedErrorItemSchemaAsync`), and every per-code variant requires `"code"` (already true via `CreateCodeSpecificSchema`). The canonical `PortableError` / `PortableValidationErrorDetail` schemas leave `code` nullable and not required so that the *un-narrowed* envelope can still describe error items that omit a code; once an endpoint opts into narrowing, the narrowed schema tightens the contract. -This means calling `WithErrorCodes(...)` is a developer-asserted contract that every error this endpoint emits carries a `code` and that code is in the documented set. If either half can be violated — code-less errors, third-party error propagation, defensive `Error.Internal(...)` paths whose codes are not enumerable up-front — the correct response is `AllowUnknownErrorCodes()`. The plan does not modify the runtime to enforce code presence on errors emitted from documented endpoints; that would be a separate `Light.PortableResults` runtime change and is out of scope here. +This means calling `WithErrorCodes(...)` is a developer-asserted contract that every error this endpoint emits carries a `code` and that code is in the documented set. If the code-set half can be violated — for example due to third-party error propagation or defensive `Error.Internal(...)` paths whose codes are not enumerable up-front — the correct response is `AllowUnknownErrorCodes()`. If an endpoint can emit code-less errors, the narrowed schema is not honest even in non-exhaustive mode; the correct response is to avoid narrowing that endpoint until a separate design explicitly supports that case. The plan does not modify the runtime to enforce code presence on errors emitted from documented endpoints; that would be a separate `Light.PortableResults` runtime change and is out of scope here. ### `AllowUnknownErrorCodes()` Builder Method @@ -80,7 +84,7 @@ new OpenApiSchema { } ``` -To avoid duplicating the canonical property definitions, `PortableResultsOpenApiSchemas` exposes exactly two new public helpers that return fresh property dictionaries: `CreatePortableProblemDetailsProperties(OpenApiDocument document)` and `CreatePortableAspNetCoreValidationProblemDetailsProperties(OpenApiDocument document)`. The first is used by both `PortableProblemDetails` and `PortableRichValidationProblemDetails` because they currently share an identical property set; the second is used by `PortableAspNetCoreValidationProblemDetails`, which carries the additional `errorDetails` slot. The existing private `CreateProblemDetailsProperties` is removed in favor of these. `InstallInto` calls the same helpers when authoring the canonical components, so canonical and derived schemas are guaranteed to stay structurally aligned. The transformer dispatches on the canonical schema id resolved by `ResolveCanonicalErrorEnvelopeSchemaId` to pick the right helper. +To avoid duplicating the canonical property definitions and required sets, `PortableResultsOpenApiSchemas` exposes shared public helpers that return fresh property dictionaries and fresh required sets: `CreatePortableProblemDetailsProperties(OpenApiDocument document)`, `CreatePortableProblemDetailsRequired()`, `CreatePortableAspNetCoreValidationProblemDetailsProperties(OpenApiDocument document)`, and `CreatePortableAspNetCoreValidationProblemDetailsRequired()`. The problem-details pair is used by both `PortableProblemDetails` and `PortableRichValidationProblemDetails` because they currently share an identical property set; the ASP.NET Core-compatible pair is used by `PortableAspNetCoreValidationProblemDetails`, which carries the additional `errorDetails` slot. The existing private `CreateProblemDetailsProperties` is removed in favor of these shared helpers. `InstallInto` calls the same helpers when authoring the canonical components, so canonical and derived schemas are guaranteed to stay structurally aligned. The transformer dispatches on the canonical schema id resolved by `ResolveCanonicalErrorEnvelopeSchemaId` to pick the right helper set. The transformer's flatten path: diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index 2584f6a..c0faeaa 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -26,12 +26,13 @@ public static void MapGetMoviesEndpoint(this WebApplication app) => "Supports keyset pagination via the optional lastKnownMovieId parameter and a configurable " + "page size via the take parameter (1-40). Returns a rich Light.PortableResults problem " + "details response when input validation fails or when the lastKnownMovieId does not exist." - ) + ) .Produces>() .ProducesPortableValidationProblem( configure: x => x .UseFormat(ValidationProblemSerializationFormat.Rich) .WithErrorCodes(ValidationErrorCodes.NotEmpty) + .WithErrorMetadata("MovieNotFound") .WithInRangeError() ); @@ -82,3 +83,10 @@ private static async Task GetMovies( return TypedResults.Ok(movies); } } + +// ReSharper disable once ClassNeverInstantiated.Global -- required for OpenAPI +public sealed class MovieNotFoundMetadata +{ + // ReSharper disable once UnusedMember.Global -- required for OpenAPI + public string LastKnownMovieId { get; init; } = string.Empty; +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 5ef29bd..05ba7ca 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -335,15 +335,11 @@ CancellationToken cancellationToken attribute.ContentType ); - var extensionSchema = new OpenApiSchema - { - Type = JsonSchemaType.Object, - Properties = new Dictionary(StringComparer.Ordinal) - }; + var properties = CreateCanonicalErrorEnvelopeProperties(document, canonicalSchemaId); if (attribute.TopLevelMetadataType is not null) { - extensionSchema.Properties["metadata"] = await GetStableSchemaReferenceAsync( + properties["metadata"] = await GetStableSchemaReferenceAsync( document, context, attribute.TopLevelMetadataType, @@ -354,11 +350,7 @@ CancellationToken cancellationToken if (documentedErrorSchema is not null) { - var propertyName = - canonicalSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId ? - "errorDetails" : - "errors"; - extensionSchema.Properties[propertyName] = new OpenApiSchema + properties[ResolveDocumentedErrorPropertyName(canonicalSchemaId)] = new OpenApiSchema { Type = JsonSchemaType.Array, Items = documentedErrorSchema @@ -367,11 +359,9 @@ CancellationToken cancellationToken var derivedSchema = new OpenApiSchema { - AllOf = - [ - PortableResultsOpenApiSchemas.CreateSchemaReference(document, canonicalSchemaId), - extensionSchema - ] + Type = JsonSchemaType.Object, + Properties = properties, + Required = CreateCanonicalErrorEnvelopeRequired(canonicalSchemaId) }; return AddComponentAndCreateReference(document, derivedEnvelopeSchemaId, derivedSchema); } @@ -481,21 +471,34 @@ CancellationToken cancellationToken return null; } - // The final item schema is a discriminated anyOf over all documented code-specific variants plus - // the generic fallback schema so undocumented error codes are still represented correctly. - var fallbackSchema = PortableResultsOpenApiSchemas.CreateSchemaReference(document, itemBaseSchemaId); - var anyOfSchemas = new List(documentedVariants.Count + 1); - anyOfSchemas.AddRange(documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference)); - anyOfSchemas.Add(fallbackSchema); - var discriminatorMapping = documentedVariants.ToDictionary( static variant => variant.RawCode, static variant => variant.SchemaReference, StringComparer.Ordinal ); + + if (attribute.AllowUnknownErrorCodes) + { + var fallbackSchema = PortableResultsOpenApiSchemas.CreateSchemaReference(document, itemBaseSchemaId); + var anyOfSchemas = new List(documentedVariants.Count + 1); + anyOfSchemas.AddRange(documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference)); + anyOfSchemas.Add(fallbackSchema); + + return new OpenApiSchema + { + AnyOf = anyOfSchemas, + Required = new HashSet(StringComparer.Ordinal) { "code" }, + Discriminator = new OpenApiDiscriminator + { + PropertyName = "code", + Mapping = discriminatorMapping + } + }; + } + return new OpenApiSchema { - AnyOf = anyOfSchemas, + OneOf = documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference).ToList(), Required = new HashSet(StringComparer.Ordinal) { "code" }, Discriminator = new OpenApiDiscriminator { @@ -830,6 +833,48 @@ ErrorMetadataContract contract rawCodeContracts.Add(code, contract); } + private static Dictionary CreateCanonicalErrorEnvelopeProperties( + OpenApiDocument document, + string canonicalSchemaId + ) + { + return canonicalSchemaId switch + { + PortableResultsOpenApiSchemas.PortableProblemDetailsSchemaId => + PortableResultsOpenApiSchemas.CreatePortableProblemDetailsProperties(document), + PortableResultsOpenApiSchemas.PortableRichValidationProblemDetailsSchemaId => + PortableResultsOpenApiSchemas.CreatePortableProblemDetailsProperties(document), + PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId => + PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsProperties(document), + _ => throw new InvalidOperationException( + $"The canonical error envelope schema id '{canonicalSchemaId}' is not supported." + ) + }; + } + + private static HashSet CreateCanonicalErrorEnvelopeRequired(string canonicalSchemaId) + { + return canonicalSchemaId switch + { + PortableResultsOpenApiSchemas.PortableProblemDetailsSchemaId => + PortableResultsOpenApiSchemas.CreatePortableProblemDetailsRequired(), + PortableResultsOpenApiSchemas.PortableRichValidationProblemDetailsSchemaId => + PortableResultsOpenApiSchemas.CreatePortableProblemDetailsRequired(), + PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId => + PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsRequired(), + _ => throw new InvalidOperationException( + $"The canonical error envelope schema id '{canonicalSchemaId}' is not supported." + ) + }; + } + + private static string ResolveDocumentedErrorPropertyName(string canonicalSchemaId) + { + return canonicalSchemaId == PortableResultsOpenApiSchemas.PortableAspNetCoreValidationProblemDetailsSchemaId ? + "errorDetails" : + "errors"; + } + private static string? NormalizePath(string? relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs index 6246013..5c0ec1a 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs @@ -24,6 +24,11 @@ string contentType /// public string[]? ErrorCodes { get; set; } + /// + /// Gets or sets a value indicating whether undocumented error codes remain allowed in the schema. + /// + public bool AllowUnknownErrorCodes { get; set; } + /// /// Gets or sets the inline error codes whose metadata schema is defined directly on the endpoint. /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index b12bf75..390df20 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -42,6 +42,15 @@ public PortableProblemOpenApiBuilder WithErrorCodes(params string[] codes) return this; } + /// + /// Keeps the schema non-exhaustive so undocumented error codes remain representable. + /// + public PortableProblemOpenApiBuilder AllowUnknownErrorCodes() + { + _attribute.AllowUnknownErrorCodes = true; + return this; + } + /// /// Registers an inline metadata contract for the specified error code. /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 8324c9a..75188c1 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -44,6 +44,15 @@ public PortableValidationProblemOpenApiBuilder WithErrorCodes(params string[] co return this; } + /// + /// Keeps the schema non-exhaustive so undocumented error codes remain representable. + /// + public PortableValidationProblemOpenApiBuilder AllowUnknownErrorCodes() + { + _attribute.AllowUnknownErrorCodes = true; + return this; + } + /// /// Registers an inline metadata contract for the specified error code. /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs index 74c4298..a8cd838 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs @@ -117,8 +117,8 @@ private static OpenApiSchema CreateErrorCategorySchema() { Type = JsonSchemaType.String, Enum = Enum.GetNames(typeof(ErrorCategory)) - .Select(static name => (JsonNode) JsonValue.Create(name)) - .ToList() + .Select(static name => (JsonNode) JsonValue.Create(name)) + .ToList() }; } @@ -161,8 +161,8 @@ private static OpenApiSchema CreatePortableProblemDetailsSchema(OpenApiDocument return new OpenApiSchema { Type = JsonSchemaType.Object, - Properties = CreateProblemDetailsProperties(document), - Required = new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" } + Properties = CreatePortableProblemDetailsProperties(document), + Required = CreatePortableProblemDetailsRequired() }; } @@ -171,8 +171,8 @@ private static OpenApiSchema CreatePortableRichValidationProblemDetailsSchema(Op return new OpenApiSchema { Type = JsonSchemaType.Object, - Properties = CreateProblemDetailsProperties(document), - Required = new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" } + Properties = CreatePortableProblemDetailsProperties(document), + Required = CreatePortableProblemDetailsRequired() }; } @@ -181,35 +181,46 @@ private static OpenApiSchema CreatePortableAspNetCoreValidationProblemDetailsSch return new OpenApiSchema { Type = JsonSchemaType.Object, - Properties = new Dictionary(StringComparer.Ordinal) + Properties = CreatePortableAspNetCoreValidationProblemDetailsProperties(document), + Required = CreatePortableAspNetCoreValidationProblemDetailsRequired() + }; + } + + /// + /// Creates a fresh property dictionary for the portable problem-details envelopes. + /// + public static Dictionary CreatePortableProblemDetailsProperties(OpenApiDocument document) + { + return new Dictionary(StringComparer.Ordinal) + { + ["type"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["title"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["status"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, + ["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, + ["errors"] = new OpenApiSchema { - ["type"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, - ["title"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, - ["status"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, - ["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, - ["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, - ["errors"] = new OpenApiSchema - { - Type = JsonSchemaType.Object, - AdditionalPropertiesAllowed = true, - AdditionalProperties = new OpenApiSchema - { - Type = JsonSchemaType.Array, - Items = new OpenApiSchema { Type = JsonSchemaType.String } - } - }, - ["errorDetails"] = new OpenApiSchema - { - Type = JsonSchemaType.Array, - Items = CreateSchemaReference(document, PortableValidationErrorDetailSchemaId) - }, - ["metadata"] = CreateOpenMetadataSchema() + Type = JsonSchemaType.Array, + Items = CreateSchemaReference(document, PortableErrorSchemaId) }, - Required = new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" } + ["metadata"] = CreateOpenMetadataSchema() }; } - private static Dictionary CreateProblemDetailsProperties(OpenApiDocument document) + /// + /// Creates a fresh required-set for the portable problem-details envelopes. + /// + public static HashSet CreatePortableProblemDetailsRequired() + { + return new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" }; + } + + /// + /// Creates a fresh property dictionary for the ASP.NET Core-compatible validation problem envelope. + /// + public static Dictionary CreatePortableAspNetCoreValidationProblemDetailsProperties( + OpenApiDocument document + ) { return new Dictionary(StringComparer.Ordinal) { @@ -219,11 +230,29 @@ private static Dictionary CreateProblemDetailsProperties ["detail"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, ["instance"] = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Null }, ["errors"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + AdditionalPropertiesAllowed = true, + AdditionalProperties = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["errorDetails"] = new OpenApiSchema { Type = JsonSchemaType.Array, - Items = CreateSchemaReference(document, PortableErrorSchemaId) + Items = CreateSchemaReference(document, PortableValidationErrorDetailSchemaId) }, ["metadata"] = CreateOpenMetadataSchema() }; } + + /// + /// Creates a fresh required-set for the ASP.NET Core-compatible validation problem envelope. + /// + public static HashSet CreatePortableAspNetCoreValidationProblemDetailsRequired() + { + return new HashSet(StringComparer.Ordinal) { "type", "title", "status", "errors" }; + } } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index 4f4655a..a8d78d7 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -30,6 +30,7 @@ public void ProducesPortableProblem_ShouldAccumulateRouteMetadata() configure: builder => builder .WithErrorCodes("First") .WithErrorCodes("Second", "Third") + .AllowUnknownErrorCodes() .WithMetadata() .WithMetadata(typeof(ProblemMetadata)) .WithErrorMetadata("Movie/Gone") @@ -41,6 +42,7 @@ public void ProducesPortableProblem_ShouldAccumulateRouteMetadata() attribute.StatusCode.Should().Be(StatusCodes.Status404NotFound); attribute.TopLevelMetadataType.Should().Be(typeof(ProblemMetadata)); attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); + attribute.AllowUnknownErrorCodes.Should().BeTrue(); attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); attribute.InlineErrorMetadataContracts.Should().Equal( ErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), @@ -57,6 +59,7 @@ public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() configure: x => x.WithErrorCodes("First") .WithErrorCodes() .WithErrorCodes("Second", "Third") + .AllowUnknownErrorCodes() .WithMetadata() .WithMetadata(typeof(ProblemMetadata)) .WithErrorMetadata("Movie/Gone") @@ -68,6 +71,7 @@ public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() attribute.TopLevelMetadataType.Should().Be(typeof(ProblemMetadata)); attribute.ErrorCodes.Should().Equal("First", "Second", "Third"); + attribute.AllowUnknownErrorCodes.Should().BeTrue(); attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); attribute.InlineErrorMetadataContracts.Should().Equal( ErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index df4cb76..cd491b5 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -8,6 +9,7 @@ using Light.PortableResults.AspNetCore.MinimalApis; using Light.PortableResults.AspNetCore.Mvc; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; using Light.PortableResults.Http.Writing; using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Builder; @@ -63,15 +65,16 @@ public async Task MinimalApiDocument_ShouldEmitConfiguredSchemas() "application/problem+json" ).Should().BeOfType().Subject; var globalProblemComponent = GetSchemaComponent(document, GetSchemaReferenceId(globalProblemSchema)); - var globalProblemExtension = (OpenApiSchema) globalProblemComponent.AllOf![1]; - var globalProblemErrors = (OpenApiSchema) globalProblemExtension.Properties!["errors"]; - var globalProblemItems = (OpenApiSchema) globalProblemErrors.Items!; - globalProblemItems.AnyOf.Should().HaveCount(3); - GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf![0]).Should() + globalProblemComponent.AllOf.Should().BeNull(); + globalProblemComponent.Type.Should().Be(JsonSchemaType.Object); + var globalProblemItems = GetErrorItems(globalProblemComponent); + globalProblemItems.AnyOf.Should().BeNull(); + globalProblemItems.OneOf.Should().HaveCount(2); + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.OneOf![0]).Should() .Be("PortableError__VersionMismatch"); - GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[1]).Should() + GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.OneOf[1]).Should() .Be("PortableError__Insufficient_Funds"); - GetSchemaReferenceId((OpenApiSchemaReference) globalProblemItems.AnyOf[2]).Should().Be("PortableError"); + globalProblemItems.Required.Should().Contain("code"); globalProblemItems.Discriminator.Should().NotBeNull(); globalProblemItems.Discriminator!.PropertyName.Should().Be("code"); globalProblemItems.Discriminator.Mapping.Should().NotBeNull(); @@ -87,11 +90,12 @@ public async Task MinimalApiDocument_ShouldEmitConfiguredSchemas() "application/problem+json" ).Should().BeOfType().Subject; var inlineProblemComponent = GetSchemaComponent(document, GetSchemaReferenceId(inlineProblemSchema)); - var inlineProblemExtension = (OpenApiSchema) inlineProblemComponent.AllOf![1]; - var inlineProblemItems = (OpenApiSchema) ((OpenApiSchema) inlineProblemExtension.Properties!["errors"]).Items!; - GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf![0]).Should().Contain("PortableError__"); - GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf[0]).Should().Contain("Movie_Gone"); - GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.AnyOf[1]).Should().Be("PortableError"); + inlineProblemComponent.AllOf.Should().BeNull(); + var inlineProblemItems = GetErrorItems(inlineProblemComponent); + inlineProblemItems.OneOf.Should().ContainSingle(); + inlineProblemItems.AnyOf.Should().BeNull(); + GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.OneOf![0]).Should().Contain("PortableError__"); + GetSchemaReferenceId((OpenApiSchemaReference) inlineProblemItems.OneOf[0]).Should().Contain("Movie_Gone"); var defaultValidationSchema = GetResponseSchema( document, @@ -146,7 +150,8 @@ public async Task MvcDocument_ShouldHonorPortableOpenApiAttributes() "application/problem+json" ).Should().BeOfType().Subject; var problemComponent = GetSchemaComponent(document, GetSchemaReferenceId(problemSchema)); - ((OpenApiSchema) problemComponent.AllOf![1]).Properties.Should().ContainKey("errors"); + problemComponent.AllOf.Should().BeNull(); + problemComponent.Properties.Should().ContainKey("errors"); var validationSchema = GetResponseSchema( document, @@ -190,6 +195,155 @@ public async Task Transformer_ShouldUseEnumNarrowingForOpenApi30AndConstForOpenA openApi31CodeSchema.Const.Should().Be("VersionMismatch"); } + [Fact] + public async Task Transformer_ShouldAllowUnknownErrorCodesWhenRequested() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/non-exhaustive", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status409Conflict, + configure: x => x + .WithErrorCodes("VersionMismatch") + .AllowUnknownErrorCodes() + ); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var items = GetReferencedErrorItems( + document, + "/minimal/problems/non-exhaustive", + HttpMethod.Get, + StatusCodes.Status409Conflict + ); + + items.OneOf.Should().BeNull(); + items.AnyOf.Should().HaveCount(2); + items.Required.Should().Contain("code"); + GetSchemaReferenceId((OpenApiSchemaReference) items.AnyOf![0]).Should().Be("PortableError__VersionMismatch"); + GetSchemaReferenceId((OpenApiSchemaReference) items.AnyOf[1]).Should().Be("PortableError"); + items.Discriminator!.Mapping!.Keys.Should().BeEquivalentTo("VersionMismatch"); + } + + [Fact] + public async Task Transformer_ShouldKeepCanonicalEnvelopeReferenceWhenNoNarrowingIsConfigured() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/plain", static () => TypedResults.Problem()) + .ProducesPortableProblem(StatusCodes.Status400BadRequest); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var schemaReference = GetResponseSchema( + document, + "/minimal/problems/plain", + HttpMethod.Get, + StatusCodes.Status400BadRequest, + "application/problem+json" + ).Should().BeOfType().Subject; + + GetSchemaReferenceId(schemaReference).Should().Be(PortableResultsOpenApiSchemas.PortableProblemDetailsSchemaId); + } + + [Fact] + public async Task Transformer_ShouldFlattenDerivedEnvelopeWhenOnlyMetadataIsNarrowed() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/metadata-only", static () => TypedResults.Problem()) + .ProducesPortableProblem( + StatusCodes.Status409Conflict, + configure: x => x.WithMetadata() + ); + } + ); + + var document = await GetOpenApiDocumentAsync(app); + var component = GetReferencedResponseComponent( + document, + "/minimal/problems/metadata-only", + HttpMethod.Get, + StatusCodes.Status409Conflict, + "application/problem+json" + ); + + component.AllOf.Should().BeNull(); + component.Type.Should().Be(JsonSchemaType.Object); + component.Properties.Should().ContainKeys( + "type", + "title", + "status", + "detail", + "instance", + "errors", + "metadata" + ); + ((OpenApiSchema) component.Properties!["errors"]).Items.Should().BeOfType(); + GetSchemaReferenceId((OpenApiSchemaReference) ((OpenApiSchema) component.Properties["errors"]).Items!) + .Should().Be(PortableResultsOpenApiSchemas.PortableErrorSchemaId); + component.Properties["metadata"].Should().BeOfType(); + } + + [Fact] + public async Task Transformer_ShouldRequireCodeForNarrowedErrorItems() + { + await using var app = CreateMinimalApiApp(); + + var document = await GetOpenApiDocumentAsync(app); + var items = GetReferencedErrorItems( + document, + "/minimal/problems/global", + HttpMethod.Get, + StatusCodes.Status409Conflict + ); + var variant = GetSchemaComponent(document, "PortableError__VersionMismatch"); + var variantExtension = (OpenApiSchema) variant.AllOf![1]; + + items.Required.Should().Contain("code"); + variantExtension.Required.Should().Contain("code"); + } + + [Fact] + public async Task Transformer_ShouldPreservePerCodeVariantAllOfShape() + { + await using var app = CreateMinimalApiApp(); + + var document = await GetOpenApiDocumentAsync(app); + var variant = GetSchemaComponent(document, "PortableError__VersionMismatch"); + + variant.AllOf.Should().HaveCount(2); + variant.AllOf![0].Should().BeOfType(); + GetSchemaReferenceId((OpenApiSchemaReference) variant.AllOf[0]).Should() + .Be(PortableResultsOpenApiSchemas.PortableErrorSchemaId); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + public async Task Transformer_ShouldProduceSpecValidDocuments(OpenApiSpecVersion openApiVersion) + { + await using var app = CreateMinimalApiApp( + configureOpenApi: options => options.OpenApiVersion = openApiVersion + ); + + var document = await GetOpenApiDocumentAsync(app); + var validationErrors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + await using var stream = new MemoryStream(); + await document.SerializeAsJsonAsync(stream, openApiVersion, TestContext.Current.CancellationToken); + + validationErrors.Should().BeEmpty(); + stream.Length.Should().BeGreaterThan(0L); + } + [Fact] public async Task Transformer_ShouldThrowWhenAnEndpointUsesAnUnknownGlobalErrorCode() { @@ -516,11 +670,10 @@ public async Task Transformer_ShouldAcceptDuplicateInlineMetadataCode_WhenTypeIs "application/problem+json" ).Should().BeOfType().Subject; var component = GetSchemaComponent(document, GetSchemaReferenceId(responseSchemaRef)); - var items = - (OpenApiSchema) ((OpenApiSchema) ((OpenApiSchema) component.AllOf![1]).Properties!["errors"]).Items!; + var items = GetErrorItems(component); - // Duplicate inline code with identical type must be deduplicated: only one documented variant + fallback. - items.AnyOf.Should().HaveCount(2); + // Duplicate inline code with identical type must be deduplicated: only one documented variant remains. + items.OneOf.Should().ContainSingle(); } [Fact] @@ -722,6 +875,38 @@ private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string return (OpenApiSchema) document.Components!.Schemas![schemaId]; } + private static OpenApiSchema GetReferencedResponseComponent( + OpenApiDocument document, + string path, + HttpMethod httpMethod, + int statusCode, + string contentType + ) + { + var schemaReference = GetResponseSchema(document, path, httpMethod, statusCode, contentType) + .Should() + .BeOfType() + .Subject; + return GetSchemaComponent(document, GetSchemaReferenceId(schemaReference)); + } + + private static OpenApiSchema GetReferencedErrorItems( + OpenApiDocument document, + string path, + HttpMethod httpMethod, + int statusCode, + string contentType = "application/problem+json" + ) + { + return GetErrorItems(GetReferencedResponseComponent(document, path, httpMethod, statusCode, contentType)); + } + + private static OpenApiSchema GetErrorItems(OpenApiSchema envelopeSchema) + { + var propertyName = envelopeSchema.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; + return (OpenApiSchema) ((OpenApiSchema) envelopeSchema.Properties[propertyName]).Items!; + } + private static string GetSchemaReferenceId(OpenApiSchemaReference schemaReference) { var referenceId = schemaReference.Reference.Id ?? schemaReference.Id; diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs index 690289c..a34ac0a 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs @@ -35,4 +35,46 @@ public void InstallInto_ShouldAddTheCanonicalSchemaCatalog() metadataSchema.Type.Should().Be(JsonSchemaType.Object | JsonSchemaType.Null); metadataSchema.AdditionalPropertiesAllowed.Should().BeTrue(); } + + [Fact] + public void PropertyFactories_ShouldReturnFreshCopies() + { + var document = new OpenApiDocument(); + + var firstProblemProperties = PortableResultsOpenApiSchemas.CreatePortableProblemDetailsProperties(document); + var secondProblemProperties = PortableResultsOpenApiSchemas.CreatePortableProblemDetailsProperties(document); + var aspNetCoreProperties = + PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsProperties(document); + var firstProblemRequired = PortableResultsOpenApiSchemas.CreatePortableProblemDetailsRequired(); + var secondProblemRequired = PortableResultsOpenApiSchemas.CreatePortableProblemDetailsRequired(); + var aspNetCoreRequired = PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsRequired(); + + firstProblemProperties.Should().NotBeSameAs(secondProblemProperties); + firstProblemProperties.Should().ContainKeys( + "type", + "title", + "status", + "detail", + "instance", + "errors", + "metadata" + ); + aspNetCoreProperties.Should().ContainKeys( + "type", + "title", + "status", + "detail", + "instance", + "errors", + "errorDetails", + "metadata" + ); + firstProblemRequired.Should().BeEquivalentTo("type", "title", "status", "errors"); + aspNetCoreRequired.Should().BeEquivalentTo("type", "title", "status", "errors"); + + firstProblemProperties.Remove("metadata"); + secondProblemProperties.Should().ContainKey("metadata"); + firstProblemRequired.Remove("errors"); + secondProblemRequired.Should().Contain("errors"); + } } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs index cfe02ca..a623e1d 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs @@ -85,17 +85,17 @@ public async Task ProducesPortableProblem_ShouldMixGlobalAndEndpointScopedBuiltI var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); var responseItems = ValidationOpenApiDocumentTestUtilities.GetProblemItems(document, "/mixed-problem"); - responseItems.AnyOf!.Select( + responseItems.AnyOf.Should().BeNull(); + responseItems.OneOf!.Select( static schema => ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) ) .Should() - .Contain( + .BeEquivalentTo( [ "PortableError__NotEmpty", "PortableError__LengthInRange", - "PortableError__MixedProblem__400__application_problem_json__InRange", - "PortableError" + "PortableError__MixedProblem__400__application_problem_json__InRange" ] ); } @@ -121,6 +121,7 @@ public async Task TypedHelpers_ShouldBeIdempotentWhenRegisteredTwice() } ); + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal var act = async () => await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); await act.Should().NotThrowAsync(); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs index 99604b3..d997ec1 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs @@ -62,12 +62,13 @@ public async Task Transformer_ShouldEmitMetadataAndNoMetadataBuiltInCodesTogethe var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); var responseItems = ValidationOpenApiDocumentTestUtilities.GetProblemItems(document, "/count-and-not-null"); - responseItems.AnyOf!.Select( + responseItems.AnyOf.Should().BeNull(); + responseItems.OneOf!.Select( static schema => ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) ) .Should() - .Contain(["PortableError__Count", "PortableError__NotNull", "PortableError"]); + .BeEquivalentTo("PortableError__Count", "PortableError__NotNull"); var countMetadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( document, "PortableError__Count__Metadata" @@ -181,18 +182,16 @@ public async Task ProducesPortableValidationProblem_ShouldMixGlobalAndEndpointSc var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); var responseItems = ValidationOpenApiDocumentTestUtilities.GetProblemItems(document, "/mixed-validation"); - responseItems.AnyOf!.Select( + responseItems.AnyOf.Should().BeNull(); + responseItems.OneOf!.Select( static schema => ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) ) .Should() - .Contain( - [ - "PortableError__NotEmpty", - "PortableError__LengthInRange", - "PortableError__MixedValidation__400__application_problem_json__InRange", - "PortableError" - ] + .BeEquivalentTo( + "PortableError__NotEmpty", + "PortableError__LengthInRange", + "PortableError__MixedValidation__400__application_problem_json__InRange" ); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs index 79fdec3..6ddc355 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs @@ -53,9 +53,8 @@ internal static OpenApiSchema GetProblemItems(OpenApiDocument document, string p .Responses![StatusCodes.Status400BadRequest.ToString(CultureInfo.InvariantCulture)]; var schema = (OpenApiSchemaReference) response.Content!["application/problem+json"].Schema!; var component = GetSchemaComponent(document, GetSchemaReferenceId(schema)); - var extension = (OpenApiSchema) component.AllOf![1]; - var propertyName = extension.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; - return (OpenApiSchema) ((OpenApiSchema) extension.Properties[propertyName]).Items!; + var propertyName = component.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; + return (OpenApiSchema) ((OpenApiSchema) component.Properties[propertyName]).Items!; } internal static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) From 133e0cbe7a8cd6737d853a529f9a13e2ceee1157 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 21:09:00 +0200 Subject: [PATCH 57/67] test: aspNetCoreRequired is now mutated Signed-off-by: Kenny Pflug --- .../PortableResultsOpenApiSchemasTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs index a34ac0a..e227703 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs @@ -47,7 +47,10 @@ public void PropertyFactories_ShouldReturnFreshCopies() PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsProperties(document); var firstProblemRequired = PortableResultsOpenApiSchemas.CreatePortableProblemDetailsRequired(); var secondProblemRequired = PortableResultsOpenApiSchemas.CreatePortableProblemDetailsRequired(); - var aspNetCoreRequired = PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsRequired(); + var aspNetCoreRequired = + PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsRequired(); + var secondAspNetCoreRequired = + PortableResultsOpenApiSchemas.CreatePortableAspNetCoreValidationProblemDetailsRequired(); firstProblemProperties.Should().NotBeSameAs(secondProblemProperties); firstProblemProperties.Should().ContainKeys( @@ -76,5 +79,7 @@ public void PropertyFactories_ShouldReturnFreshCopies() secondProblemProperties.Should().ContainKey("metadata"); firstProblemRequired.Remove("errors"); secondProblemRequired.Should().Contain("errors"); + aspNetCoreRequired.Remove("errors"); + secondAspNetCoreRequired.Should().Contain("errors"); } } From cece303e181b60cc4debc662087757c51ce76a29 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 30 Apr 2026 21:26:04 +0200 Subject: [PATCH 58/67] chore: add plan deviations for 40 OpenAPI support Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0040-6-plan-deviations.md | 182 +++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 ai-plans/0040-6-plan-deviations.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 7fe6fc5..0a9bf74 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -55,6 +55,7 @@ + diff --git a/ai-plans/0040-6-plan-deviations.md b/ai-plans/0040-6-plan-deviations.md new file mode 100644 index 0000000..b346919 --- /dev/null +++ b/ai-plans/0040-6-plan-deviations.md @@ -0,0 +1,182 @@ +# 0040 Plan Deviations + +This document compares the original OpenAPI plan in `ai-plans/0040-0-openapi-support.md` with the implementation direction that was ultimately taken across `0040-1` through `0040-5`. + +## Summary + +The original plan treated OpenAPI support as a thin, schema-only CLR layer on top of the existing ASP.NET Core integrations. The final implementation went in a different direction: OpenAPI became its own opt-in package, schema generation moved from surrogate CLR types to a document transformer plus a library-authored schema catalog, validation error contracts became code-driven and registry-based, and later follow-up plans tightened the design for package boundaries, NativeAOT, coverage, and downstream OpenAPI tooling behavior. + +The most important architectural change is that OpenAPI is no longer modeled primarily through public CLR response types such as `PortableProblemDetails` or `PortableSuccessResponse`. Instead, the library now owns the OpenAPI document directly and synthesizes response schemas from endpoint metadata. + +## Major Deviations From The Original Plan + +### 1. OpenAPI moved out of the runtime ASP.NET Core packages into dedicated opt-in packages + +**Original plan:** +`Light.PortableResults.AspNetCore.Shared` would contain the schema-only OpenAPI CLR types, `Light.PortableResults.AspNetCore.MinimalApis` would expose the `RouteHandlerBuilder` helpers, and `Light.PortableResults.AspNetCore.Mvc` would expose the response metadata attributes. No separate OpenAPI package was introduced. + +**Implemented direction:** +OpenAPI support was moved into a new dedicated package, `Light.PortableResults.AspNetCore.OpenApi`, with its own service-registration entry point, `AddPortableResultsOpenApi()`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` no longer expose the OpenAPI helper or attribute surface at all. A second bridge package, `Light.PortableResults.Validation.OpenApi`, was later added for validation-specific built-in error contracts. The redesign also explicitly targets `Microsoft.AspNetCore.OpenApi`; Swashbuckle / NSwag-specific integration is not part of the public surface. + +**Impact:** +This is a major packaging and layering deviation. The final design keeps the runtime packages free of the `Microsoft.AspNetCore.OpenApi` dependency and makes OpenAPI support an explicit opt-in concern instead of part of the core ASP.NET Core integration surface. + +### 2. The schema-only CLR surrogate model was abandoned entirely + +**Original plan:** +The public OpenAPI model was centered around schema-only CLR types such as: + +- `PortableSuccessResponse` +- `PortableError` and `PortableError` +- `PortableValidationErrorDetail` and `PortableValidationErrorDetail` +- `PortableProblemDetails` +- `PortableRichValidationProblemDetails` +- `PortableAspNetCoreValidationProblemDetails` + +OpenAPI generators were expected to infer schemas from those CLR types. + +**Implemented direction:** +That entire surrogate model was removed. The library now authors canonical OpenAPI schemas directly through `PortableResultsOpenApiSchemas` and uses `PortableResultsOpenApiDocumentTransformer` to install canonical components and synthesize operation-specific derived schemas. + +**Impact:** +This is the core architectural pivot. It avoids generic CLR type names leaking into OpenAPI component ids, removes the need for alias hierarchies and naming workarounds, and stops promising metadata CLR shapes that the runtime HTTP writers do not actually enforce. + +### 3. The success-response design changed from a metadata-generic CLR wrapper to a mode-aware single-generic helper + +**Original plan:** +The success-side OpenAPI helper existed only for the wrapped `{ value, metadata }` body shape and always required an explicit metadata type through `PortableSuccessResponse`, `ProducesPortableSuccessResponse`, and `ProducesPortableSuccessResponseAttribute`. Plain `TValue` success responses were supposed to use standard ASP.NET Core OpenAPI APIs. + +**Implemented direction:** +The final design collapsed the public success helper to `ProducesPortableSuccessResponse` and `ProducesPortableSuccessResponseAttribute`. The generated success schema is now selected from the effective `MetadataSerializationMode`: under `ErrorsOnly` it documents the bare `TValue` response shape, and under `Always` it synthesizes a wrapped `{ value, metadata }` envelope. Top-level metadata can still be narrowed explicitly, but it is no longer a public generic parameter on the helper surface. + +**Impact:** +This is both an API-shape deviation and a behavioral one. The success-side OpenAPI surface is now mode-aware and can follow the application default from `PortableResultsHttpWriteOptions`, which is more dynamic than the strictly static, metadata-generic model described in `0040-0`. The transient rename from `WrappedResponse` to `PortableSuccessResponse` became a short-lived intermediate state rather than the final contract. + +### 4. Separate validation helper families were collapsed into one validation helper with format selection + +**Original plan:** +Minimal APIs and MVC would expose separate helper/attribute families for: + +- general problems +- rich validation problems +- ASP.NET Core-compatible validation problems + +The split was intentional so callers had to choose the exact validation schema shape explicitly. + +**Implemented direction:** +The final public surface exposes only: + +- `ProducesPortableProblem` +- `ProducesPortableValidationProblem` +- `ProducesPortableProblemAttribute` +- `ProducesPortableValidationProblemAttribute` + +The effective validation schema is resolved from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` or a per-endpoint/per-attribute override. The MVC attributes are no longer `ProducesResponseTypeAttribute` wrappers; they are custom endpoint metadata attributes consumed directly by the OpenAPI document transformer. + +**Impact:** +This is a real API simplification relative to the original plan. Instead of encoding the validation format in the helper name, the final design keeps one validation helper and lets the transformer choose the canonical validation schema based on the effective format. + +### 5. Metadata typing moved from public generic parameters to explicit schema narrowing and a contract registry + +**Original plan:** +Metadata typing was expressed directly in public generic parameters such as `TErrorMetadata`, `TErrorDetailMetadata`, and `TProblemMetadata`. The documented contract for metadata was therefore tied to CLR generic arguments on the public API. + +**Implemented direction:** +The final design treats metadata slots as open objects by default and narrows them explicitly only when the caller opts in. Top-level metadata narrowing is attached through endpoint metadata. Per-error-code metadata narrowing is driven through `ConfigureErrorMetadataContracts(...)`, `PortableErrorMetadataContractsBuilder`, `IPortableErrorMetadataContractRegistry`, and inline `WithErrorMetadata(...)` overrides. + +This later expanded again in `0040-2`, where contracts were widened from "CLR type only" to a closed discriminated union: + +- CLR type contracts +- schema-factory contracts +- explicit no-metadata contracts + +**Impact:** +This is a substantial conceptual deviation. The OpenAPI layer no longer assumes that one public CLR generic argument can faithfully describe the runtime metadata shape. Instead, metadata documentation is selective, per-endpoint, and often per-error-code. + +### 6. Error-code-specific contracts became a first-class part of the OpenAPI model + +**Original plan:** +The plan documented only coarse response envelopes. It did not define a registry for specific error codes, code-discriminated unions, or endpoint-level narrowing of `errors[*].metadata` and `errorDetails[*].metadata`. + +**Implemented direction:** +The final design introduced error-code-aware OpenAPI generation. Endpoints can declare documented codes through `WithErrorCodes(...)`, register global metadata contracts in DI, and add inline per-endpoint metadata contracts for specific codes. The transformer emits per-code schema variants, discriminator mappings, and narrowed response envelopes. + +`0040-2` then went further by adding `ValidationErrorCodes`, `BuiltInValidationErrorContracts`, and `RegisterBuiltInValidationErrors()` so the built-in validation taxonomy is available as a reusable OpenAPI contract catalog instead of requiring every consumer to redeclare it. + +**Impact:** +This is not just a deviation but a major expansion beyond the original plan. The final OpenAPI surface documents individual error-code contracts rather than only top-level problem-envelope shapes. + +### 7. Validation-specific OpenAPI support became a bridge package with built-in catalogs and typed helpers + +**Original plan:** +Validation support was limited to documenting one of two validation problem envelope shapes through the shared schema-only CLR types. + +**Implemented direction:** +`0040-2` introduced `Light.PortableResults.Validation.OpenApi` as a dedicated bridge package. That package owns: + +- the built-in validation error contract catalog +- `RegisterBuiltInValidationErrors()` +- `ValidationErrorCodes` +- typed builder extensions such as `WithInRangeError()`, `WithGreaterThanError()`, and related helpers for site-specific narrowing of polymorphic built-in codes + +The plan also renamed several validation error codes for clarity: `LengthIn` became `LengthInRange`, `Matches` became `Pattern`, `IsInBetween` became `InRange`, and `NotInBetween` became `NotInRange`. + +**Impact:** +This is a broader validation/OpenAPI integration model than `0040-0` described. OpenAPI documentation for validation is now organized around a shared framework-owned code taxonomy plus optional endpoint-level narrowing. + +### 8. NativeAOT forced another design pivot away from CLR surrogates used by typed validation helpers + +**Original plan:** +The original plan did not center NativeAOT as a design constraint for OpenAPI schema generation. + +**Implemented direction:** +`0040-4` found that the typed validation helper path still relied on CLR record surrogates flowing through the ASP.NET Core schema generator, which breaks in NativeAOT unless every generated type is in the application's `JsonSerializerContext`. The fix was to delete those helper-only CLR record surrogates and switch the typed validation helpers to schema-factory contracts instead. The public `PortableOpenApiSchemaTypeMapper` was added to map CLR primitive-like types to `OpenApiSchema`, and inline endpoint metadata now stores `PortableErrorMetadataContract` values rather than just `Type` values. + +**Impact:** +This is another strong deviation from the CLR-surrogate mindset of `0040-0`. Even where the redesign had briefly kept CLR types for endpoint-scoped narrowing, the final direction removed them in favor of schema factories so the OpenAPI stack remains NativeAOT-compatible. + +### 9. The final error-union model became exhaustive-by-default and the derived envelopes were flattened + +**Original plan:** +The original plan did not describe per-error-code discriminated unions at all. It also assumed inheritance/composition through CLR schema types rather than transformer-authored flattened schemas. + +**Implemented direction:** +`0040-5` tightened the document model again: + +- documented error codes are exhaustive by default +- `AllowUnknownErrorCodes()` is the explicit opt-out for non-exhaustive endpoints +- narrowed item unions use `oneOf` without a fallback branch in exhaustive mode +- derived problem envelopes are flattened into concrete object schemas instead of outer `allOf` composition against the canonical envelope +- shared property/required helpers in `PortableResultsOpenApiSchemas` became the single source of truth for both canonical and derived envelopes + +**Impact:** +The final document shape is considerably more precise than `0040-0` envisioned and is tuned for downstream tooling behavior in Swagger UI, Scalar, Kiota, NSwag, and openapi-generator. This is another area where the implementation went materially beyond the original plan rather than simply implementing it differently. + +### 10. The testing strategy changed from package-local helper tests to package-scoped, document-generation-heavy coverage + +**Original plan:** +The plan expected tests for the Minimal API helpers, MVC attributes, and the renamed success helpers inside the existing ASP.NET Core test projects, with a new MVC test class for attribute metadata. + +**Implemented direction:** +The final design introduced dedicated package-oriented test coverage: + +- `Light.PortableResults.AspNetCore.OpenApi.Tests` +- `Light.PortableResults.Validation.OpenApi.Tests` + +`0040-3` explicitly reorganized the tests around those package boundaries, preferred sociable in-memory OpenAPI document-generation tests over isolated surface checks, and tracked coverage with `coverage.runsettings` so generated files do not distort the numbers. + +**Impact:** +This is a practical deviation in delivery strategy. The tests now validate the transformer-driven package design end to end instead of primarily asserting helper registration behavior in the original runtime packages. + +## Original Intent That Survived + +Not everything changed. Two important parts of `0040-0` still describe the final design accurately: + +- OpenAPI support remains documentation-only. The work did not change the runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, or the JSON writers in `Light.PortableResults`. +- The caller still has to document the actual response shape deliberately. Even though the final implementation is more dynamic than the original plan, it still relies on explicit endpoint metadata, explicit error-code registration, and explicit opt-ins rather than trying to infer the complete OpenAPI contract automatically from runtime behavior. + +## Net Result + +The original plan was a CLR-type-centric OpenAPI layer embedded into the ASP.NET Core runtime packages. The implemented direction is a package-separated, transformer-driven, error-code-aware OpenAPI system with validation-specific bridge packages, NativeAOT-safe schema factories, and a more precise final schema model for downstream tooling. + +In short: `0040-0` proposed "document PortableResults by exposing schema-only CLR response types." The final implementation became "generate an OpenAPI document directly from explicit endpoint metadata and library-owned schema building blocks." \ No newline at end of file From 02f6af70c3649ca37ee559400f957aea64d5e880 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:08:19 +0200 Subject: [PATCH 59/67] chore: fix requests.http routes Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/requests.http | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/NativeAotMovieRating/requests.http b/samples/NativeAotMovieRating/requests.http index 440ddf9..d917f6f 100644 --- a/samples/NativeAotMovieRating/requests.http +++ b/samples/NativeAotMovieRating/requests.http @@ -1,11 +1,11 @@ ### Health -http://localhost:5000 +http://localhost:5000/health ### OpenAPI document (JSON) http://localhost:5000/openapi/v1.json ### Scalar API reference (open in browser) -http://localhost:5000/scalar/v1 +http://localhost:5000/docs ### Get Movies (first page) http://localhost:5000/api/movies From b2f8113cb8a5b7dbf45bb29c596e34d0c0ea8d27 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:31:58 +0200 Subject: [PATCH 60/67] fix: restructure DiagnosticName to SchemaId in ErrorMetadataSchemaContract, remove corresponding CallerArgumentExpressionAttribute Signed-off-by: Kenny Pflug --- .../ErrorContracts/ErrorMetadataContract.cs | 7 ++- .../ErrorMetadataContractsBuilder.cs | 7 ++- .../ErrorMetadataSchemaContract.cs | 33 +++++++------- .../PortableResultsOpenApiMessages.cs | 2 +- .../PortableProblemOpenApiBuilder.cs | 5 +-- ...PortableValidationProblemOpenApiBuilder.cs | 5 +-- ...tionErrorContractRegistrationExtensions.cs | 2 +- .../ErrorMetadataContractTests.cs | 44 +++++++------------ .../PortableOpenApiResponseBuilderTests.cs | 3 +- 9 files changed, 45 insertions(+), 63 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs index a8d905a..9046ea3 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using Microsoft.OpenApi; namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -31,14 +30,14 @@ public static ErrorMetadataContract FromType(Type metadataType) /// Creates a contract backed by a schema factory. /// /// The factory that creates a fresh metadata schema for the requested OpenAPI version. - /// The optional diagnostic name used in duplicate-contract errors. + /// The schema ID that uniquely identifies this contract. When null, the ID is derived from the factory's method metadata. /// The metadata contract. public static ErrorMetadataContract FromSchema( Func schemaFactory, - [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + string? schemaId = null ) { ArgumentNullException.ThrowIfNull(schemaFactory); - return new ErrorMetadataSchemaContract(schemaFactory, diagnosticName); + return new ErrorMetadataSchemaContract(schemaFactory, schemaId); } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs index 56a2a1c..6e184a5 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using Light.PortableResults.AspNetCore.OpenApi.Generation; using Light.PortableResults.AspNetCore.OpenApi.Schemas; using Microsoft.OpenApi; @@ -41,14 +40,14 @@ public ErrorMetadataContractsBuilder ForCode(string code, Type metadataType) /// /// The error code to register. /// The factory that creates a fresh metadata schema for the requested OpenAPI version. - /// The optional diagnostic name used in duplicate-contract errors. + /// The schema ID that uniquely identifies this contract. When null, the ID is derived from the factory's method metadata. public ErrorMetadataContractsBuilder ForCode( string code, Func metadataSchemaFactory, - [CallerArgumentExpression(nameof(metadataSchemaFactory))] string? diagnosticName = null + string? schemaId = null ) { - return ForCode(code, ErrorMetadataContract.FromSchema(metadataSchemaFactory, diagnosticName)); + return ForCode(code, ErrorMetadataContract.FromSchema(metadataSchemaFactory, schemaId)); } /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs index f381d53..9b157cd 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using Microsoft.OpenApi; namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; @@ -13,15 +12,15 @@ public sealed class ErrorMetadataSchemaContract : ErrorMetadataContract /// Initializes a new instance of . /// /// The factory that creates a fresh metadata schema for the requested OpenAPI version. - /// The optional diagnostic name used in duplicate-contract errors. + /// The schema ID that uniquely identifies this contract. When null, the ID is derived from the factory's method metadata. public ErrorMetadataSchemaContract( Func schemaFactory, - [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + string? schemaId = null ) { ArgumentNullException.ThrowIfNull(schemaFactory); SchemaFactory = schemaFactory; - DiagnosticName = CreateDiagnosticName(schemaFactory, diagnosticName); + SchemaId = CreateSchemaId(schemaFactory, schemaId); } /// @@ -30,36 +29,36 @@ public ErrorMetadataSchemaContract( public Func SchemaFactory { get; } /// - /// Gets the diagnostic name used in duplicate-contract errors. + /// Gets the schema ID that uniquely identifies this contract and appears in duplicate-registration errors. /// - public string DiagnosticName { get; } + public string SchemaId { get; } /// public override bool Equals(object? obj) => obj is ErrorMetadataSchemaContract other && - string.Equals(DiagnosticName, other.DiagnosticName, StringComparison.Ordinal); + string.Equals(SchemaId, other.SchemaId, StringComparison.Ordinal); /// - public override int GetHashCode() => DiagnosticName.GetHashCode(StringComparison.Ordinal); + public override int GetHashCode() => SchemaId.GetHashCode(StringComparison.Ordinal); - private static string CreateDiagnosticName( + private static string CreateSchemaId( Func schemaFactory, - string? diagnosticName + string? schemaId ) { - if (!string.IsNullOrWhiteSpace(diagnosticName)) + if (!string.IsNullOrWhiteSpace(schemaId)) { - return diagnosticName; + return schemaId; } var method = schemaFactory.Method; var methodName = method.Name; var declaringTypeName = method.DeclaringType?.FullName ?? method.DeclaringType?.Name; - if (string.IsNullOrWhiteSpace(methodName) || methodName.Contains("<", StringComparison.Ordinal)) + if (string.IsNullOrWhiteSpace(methodName) || methodName.Contains('<', StringComparison.Ordinal)) { throw new InvalidOperationException( - "A schema-based error metadata contract requires a meaningful diagnostic name. " + - "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." + "A schema-based error metadata contract requires a meaningful schema ID. " + + "Pass the schemaId argument explicitly when registering anonymous or compiler-generated schema factories." ); } @@ -69,8 +68,8 @@ private static string CreateDiagnosticName( } throw new InvalidOperationException( - "A schema-based error metadata contract requires a meaningful diagnostic name. " + - "Pass the diagnosticName argument explicitly when registering anonymous or compiler-generated schema factories." + "A schema-based error metadata contract requires a meaningful schema ID. " + + "Pass the schemaId argument explicitly when registering anonymous or compiler-generated schema factories." ); } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs index 50b0cf7..e7901b8 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -37,5 +37,5 @@ private static string DescribeContract(ErrorMetadataContract contract) } private static string DescribeSchemaFactory(ErrorMetadataSchemaContract schemaContract) - => "schema factory " + schemaContract.DiagnosticName; + => "schema factory " + schemaContract.SchemaId; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index 390df20..a8cbd09 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Microsoft.OpenApi; @@ -84,7 +83,7 @@ public PortableProblemOpenApiBuilder WithErrorMetadata(string code) public PortableProblemOpenApiBuilder WithErrorMetadata( string code, Func schemaFactory, - [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + string? schemaId = null ) { ArgumentNullException.ThrowIfNull(code); @@ -96,7 +95,7 @@ public PortableProblemOpenApiBuilder WithErrorMetadata( ); _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( _attribute.InlineErrorMetadataContracts, - ErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) + ErrorMetadataContract.FromSchema(schemaFactory, schemaId) ); return this; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 75188c1..052c71a 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Light.PortableResults.Http.Writing; using Microsoft.OpenApi; @@ -86,7 +85,7 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata(stri public PortableValidationProblemOpenApiBuilder WithErrorMetadata( string code, Func schemaFactory, - [CallerArgumentExpression(nameof(schemaFactory))] string? diagnosticName = null + string? schemaId = null ) { ArgumentNullException.ThrowIfNull(code); @@ -98,7 +97,7 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata( ); _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( _attribute.InlineErrorMetadataContracts, - ErrorMetadataContract.FromSchema(schemaFactory, diagnosticName) + ErrorMetadataContract.FromSchema(schemaFactory, schemaId) ); return this; } diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs index 3c13d19..ea28d2f 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContractRegistrationExtensions.cs @@ -27,7 +27,7 @@ this ErrorMetadataContractsBuilder builder builder.ForCode(code, typeContract.MetadataType); break; case ErrorMetadataSchemaContract schemaContract: - builder.ForCode(code, schemaContract.SchemaFactory); + builder.ForCode(code, schemaContract.SchemaFactory, schemaContract.SchemaId); break; case NoMetadataContract: builder.ForCode(code); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs index 512e8eb..3c941a2 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs @@ -16,14 +16,14 @@ public void ContractFactories_ShouldExposeClosedSubclassPayloads() Func schemaFactory = _ => new OpenApiSchema(); var typeContract = ErrorMetadataContract.FromType(typeof(TestMetadata)); - var schemaContract = ErrorMetadataContract.FromSchema(schemaFactory); + var schemaContract = ErrorMetadataContract.FromSchema(schemaFactory, "test schema"); var noMetadata = ErrorMetadataContract.NoMetadata; typeContract.Should().BeOfType() .Which.MetadataType.Should().Be(typeof(TestMetadata)); schemaContract.Should().BeOfType() .Which.SchemaFactory.Should().BeSameAs(schemaFactory); - ((ErrorMetadataSchemaContract) schemaContract).DiagnosticName.Should().Be(nameof(schemaFactory)); + ((ErrorMetadataSchemaContract) schemaContract).SchemaId.Should().Be("test schema"); noMetadata.Should().BeOfType(); ErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); typeof(ErrorMetadataContract) @@ -49,8 +49,8 @@ public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() builder.ForCode("TypeCode"); builder.ForCode("TypeCode", typeof(TestMetadata)); - builder.ForCode("SchemaCode", schemaFactory); - builder.ForCode("SchemaCode", schemaFactory); + builder.ForCode("SchemaCode", schemaFactory, "my schema"); + builder.ForCode("SchemaCode", schemaFactory, "my schema"); builder.ForCode("NoMetadataCode"); builder.ForCode("NoMetadataCode"); @@ -59,7 +59,7 @@ public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() } [Fact] - public void SchemaContracts_ShouldAllowExplicitDiagnosticNames() + public void SchemaContracts_ShouldAllowExplicitSchemaIds() { // ReSharper disable once ConvertToLocalFunction Func schemaFactory = _ => new OpenApiSchema(); @@ -67,29 +67,24 @@ public void SchemaContracts_ShouldAllowExplicitDiagnosticNames() var schemaContract = ErrorMetadataContract.FromSchema(schemaFactory, "named schema"); schemaContract.Should().BeOfType() - .Which.DiagnosticName.Should().Be("named schema"); + .Which.SchemaId.Should().Be("named schema"); } [Fact] - public void SchemaContracts_ShouldDeriveDiagnosticNamesFromMethodMetadata_WhenNoNameIsAvailable() + public void SchemaContracts_ShouldDeriveSchemaIdFromMethodInfo_WhenNullIsPassed() { - var schemaContract = new ErrorMetadataSchemaContract(CreateSchema, null); + var schemaContract = new ErrorMetadataSchemaContract(CreateSchema); - schemaContract.DiagnosticName.Should().Contain(nameof(CreateSchema)); + schemaContract.SchemaId.Should().Contain(nameof(CreateSchema)); } [Fact] - public void SchemaContracts_ShouldThrow_WhenNoMeaningfulDiagnosticNameCanBeDerived() + public void SchemaContracts_ShouldThrow_WhenNoMeaningfulSchemaIdCanBeDerived() { - new Action( - () => ErrorMetadataContract.FromSchema( - _ => new OpenApiSchema(), - null - ) - ) + new Action(() => ErrorMetadataContract.FromSchema(_ => new OpenApiSchema())) .Should() .Throw() - .WithMessage("*meaningful diagnostic name*diagnosticName*"); + .WithMessage("*meaningful schema ID*schemaId*"); } [Fact] @@ -107,15 +102,6 @@ public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() .Throw() .WithMessage("*Conflict*TestMetadata*OtherMetadata*"); - new Action( - () => new ErrorMetadataContractsBuilder() - .ForCode("Conflict", firstFactory) - .ForCode("Conflict", secondFactory) - ) - .Should() - .Throw() - .WithMessage("*Conflict*firstFactory*secondFactory*"); - new Action( () => new ErrorMetadataContractsBuilder() .ForCode("Conflict", firstFactory, "first schema") @@ -185,13 +171,13 @@ public void NoMetadataContract_ShouldReturn0_WhenGetHashCodeIsCalled() => ErrorMetadataContract.NoMetadata.GetHashCode().Should().Be(0); [Fact] - public void PortableErrorMetadataSchemaContract_ShouldReturnHashCodeFromDiagnosticName() + public void ErrorMetadataSchemaContract_ShouldReturnHashCodeFromSchemaId() { - var schemaContract = new ErrorMetadataSchemaContract(CreateSchema, null); + var schemaContract = new ErrorMetadataSchemaContract(CreateSchema); var hashCode = schemaContract.GetHashCode(); - var expectedHashCode = schemaContract.DiagnosticName.GetHashCode(StringComparison.Ordinal); + var expectedHashCode = schemaContract.SchemaId.GetHashCode(StringComparison.Ordinal); hashCode.Should().Be(expectedHashCode); } diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index a8d78d7..ec19de2 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -129,7 +129,8 @@ public async Task Transformer_ShouldKeepTypeAndSchemaContractEnvelopeShapeEquiva ["value"] = new OpenApiSchema { Type = JsonSchemaType.Integer } }, Required = new HashSet(StringComparer.Ordinal) { "value" } - } + }, + "Equivalent schema" ), endpoints => { From a9144dbb4828211f6bab1c1aa79324ccbbc28bfb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:36:05 +0200 Subject: [PATCH 61/67] fix: WithErrorMetadata on OpenAPI builders now perform NullOrWhiteSpace checks Signed-off-by: Kenny Pflug --- .../PortableProblemOpenApiBuilder.cs | 4 ++-- .../PortableValidationProblemOpenApiBuilder.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index a8cbd09..577e939 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -55,7 +55,7 @@ public PortableProblemOpenApiBuilder AllowUnknownErrorCodes() /// public PortableProblemOpenApiBuilder WithErrorMetadata(string code, Type metadataType) { - ArgumentNullException.ThrowIfNull(code); + ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentNullException.ThrowIfNull(metadataType); _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( @@ -86,7 +86,7 @@ public PortableProblemOpenApiBuilder WithErrorMetadata( string? schemaId = null ) { - ArgumentNullException.ThrowIfNull(code); + ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentNullException.ThrowIfNull(schemaFactory); _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 052c71a..6dd60f8 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -57,7 +57,7 @@ public PortableValidationProblemOpenApiBuilder AllowUnknownErrorCodes() /// public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code, Type metadataType) { - ArgumentNullException.ThrowIfNull(code); + ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentNullException.ThrowIfNull(metadataType); _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( @@ -88,7 +88,7 @@ public PortableValidationProblemOpenApiBuilder WithErrorMetadata( string? schemaId = null ) { - ArgumentNullException.ThrowIfNull(code); + ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentNullException.ThrowIfNull(schemaFactory); _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( From 59e17813718fe8963e886be7b283a4cbdfeea5bb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:38:02 +0200 Subject: [PATCH 62/67] chore: fix return type in NewMovie endpoint in NativeAotMovieRating sample Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs index 4c98e02..8800be3 100644 --- a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs @@ -6,6 +6,7 @@ using Light.PortableResults.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using NativeAotMovieRating.InMemoryDatabaseAccess; namespace NativeAotMovieRating.NewMovie; @@ -19,7 +20,7 @@ public static void MapNewMovieEndpoint(this WebApplication app) => .WithDescription( "Validates the request and stores a new movie. Returns the stored movie on success, or a rich Light.PortableResults problem details response on validation failures." ) - .Produces() + .Produces() .ProducesPortableValidationProblem( configure: x => x .UseFormat(ValidationProblemSerializationFormat.Rich) From aae55e79d308f4f86e4651d2a3c2848c0e584168 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:38:55 +0200 Subject: [PATCH 63/67] chore: use correct method name in NewMovieEndpoint Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs index 8800be3..546c1ff 100644 --- a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs @@ -13,7 +13,7 @@ namespace NativeAotMovieRating.NewMovie; public static class AddNewMovieEndpoint { public static void MapNewMovieEndpoint(this WebApplication app) => - app.MapPut("/api/movies", NewMovieRating) + app.MapPut("/api/movies", NewMovie) .WithName("AddNewMovie") .WithTags("Movies") .WithSummary("Adds a new movie.") @@ -28,7 +28,7 @@ public static void MapNewMovieEndpoint(this WebApplication app) => ) .ProducesPortableProblem(); - private static async Task NewMovieRating( + private static async Task NewMovie( NewMovieDto dto, NewMovieService service, CancellationToken cancellationToken = default From e27dd7972c05ca6682a7008ad8908835066e96de Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:52:36 +0200 Subject: [PATCH 64/67] chore: ignore .codex files Signed-off-by: Kenny Pflug --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 91d35d7..a7eef41 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ obj/ *.cobertura.xml *.received.* .DS_Store -*.lscache \ No newline at end of file +*.lscache +.codex From f003689db5b040cca7a3581ec3101e0b73bd57ae Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:53:03 +0200 Subject: [PATCH 65/67] chore: GetMoviesEndpoint now uses validation for MovieNotFound Signed-off-by: Kenny Pflug --- samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index c0faeaa..58a026e 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -72,7 +72,7 @@ private static async Task GetMovies( { Message = "There is no movie with the specified ID", Target = nameof(lastKnownMovieId), - Category = ErrorCategory.Validation, // Results in HTTP 404 Not Found + Category = ErrorCategory.Validation, // Results in HTTP 400 Bad Request - invalid movie ID Code = "MovieNotFound", // Should be custom to your app and identify the error uniquely Metadata = MetadataObject.Create((nameof(lastKnownMovieId), lastKnownMovieId!.Value.ToString())) } From 85c4755e2195d10eba5bd5562eb80e1c83d291bd Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 06:56:16 +0200 Subject: [PATCH 66/67] chore: GetRangeAfterMovieId now only returns null when the ID could not be found Signed-off-by: Kenny Pflug --- .../NativeAotMovieRating/GetMovies/InMemoryGetMoviesSession.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/NativeAotMovieRating/GetMovies/InMemoryGetMoviesSession.cs b/samples/NativeAotMovieRating/GetMovies/InMemoryGetMoviesSession.cs index dcede49..89f5780 100644 --- a/samples/NativeAotMovieRating/GetMovies/InMemoryGetMoviesSession.cs +++ b/samples/NativeAotMovieRating/GetMovies/InMemoryGetMoviesSession.cs @@ -30,7 +30,7 @@ public sealed class InMemoryGetMoviesSession : IGetMoviesSession private List? GetRangeAfterMovieId(Guid lastKnownMovieId, int take) { var index = _database.Movies.FindIndex(x => x.Id == lastKnownMovieId); - if (index == -1 || index == _database.Movies.Count - 1) + if (index == -1) return null; var remaining = _database.Movies.Count - (index + 1); From 7ee385be6f6d99754d195410852ff90dfae50ef9 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 1 May 2026 07:10:34 +0200 Subject: [PATCH 67/67] fix: InMemoryMovieDatabase now produces unique IDs Signed-off-by: Kenny Pflug --- .../InMemoryMovieDatabase.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/samples/NativeAotMovieRating/InMemoryDatabaseAccess/InMemoryMovieDatabase.cs b/samples/NativeAotMovieRating/InMemoryDatabaseAccess/InMemoryMovieDatabase.cs index 853b403..d143dc1 100644 --- a/samples/NativeAotMovieRating/InMemoryDatabaseAccess/InMemoryMovieDatabase.cs +++ b/samples/NativeAotMovieRating/InMemoryDatabaseAccess/InMemoryMovieDatabase.cs @@ -693,23 +693,23 @@ public sealed class InMemoryMovieDatabase new () { Id = Guid.Parse("1A85E655-7B3E-8177-30C4-1C4B9F848E53"), Title = "Dune: Part Two" }, new () { Id = Guid.Parse("D2840DB7-4A89-732D-924C-1897F130E267"), Title = "Oppenheimer" }, new () { Id = Guid.Parse("F7954932-2B8E-5472-0B95-52B3C5389650"), Title = "Everything Everywhere All at Once" }, - new () { Id = Guid.Parse("29267924-E850-4E80-99E7-B0E3C7585F81"), Title = "Top Gun: Maverick" }, - new () { Id = Guid.Parse("6620C716-1051-4074-90E9-4672B6E32115"), Title = "Spiderman: No Way Home" }, - new () { Id = Guid.Parse("981D5E9D-A310-47F1-9788-3E9E0F07E639"), Title = "Arrival" }, - new () { Id = Guid.Parse("11E0C665-42A9-4A99-92F6-B603D67587E2"), Title = "The Revenant" }, - new () { Id = Guid.Parse("BD8E2C6A-60B0-4A3C-B7D4-4903E7E6237D"), Title = "Birdman" }, - new () { Id = Guid.Parse("7E732E96-8575-4775-A8F8-86C7C83C0F5E"), Title = "12 Years a Slave" }, - new () { Id = Guid.Parse("F56C87C1-D271-41F1-A89E-70C923B2D70F"), Title = "The Wolf of Wall Street" }, - new () { Id = Guid.Parse("7C04A02A-2330-4E15-9988-59239D80B5C1"), Title = "Her" }, - new () { Id = Guid.Parse("A3E54564-85C1-4D6F-9721-50798C5D30F4"), Title = "The Social Network" }, - new () { Id = Guid.Parse("D1E2D305-B7D3-4D90-A880-934C7A8679A9"), Title = "No Country for Old Men" }, - new () { Id = Guid.Parse("849D64C1-30D8-49A8-9B6C-7F6F1F6D439A"), Title = "There Will Be Blood" }, - new () { Id = Guid.Parse("90C1A866-9311-4773-BC3D-5C94697F8799"), Title = "Pan's Labyrinth" }, - new () { Id = Guid.Parse("A890E6D1-C9D1-4E8F-8E99-568C360C3E5A"), Title = "Casino Royale" }, - new () { Id = Guid.Parse("184B8E1D-4A2B-4E9A-A890-E6D1C9D14E8F"), Title = "Million Dollar Baby" }, - new () { Id = Guid.Parse("D77E1E8B-6E74-4C5D-80B9-72E56B641B66"), Title = "Big Fish" }, - new () { Id = Guid.Parse("7B3E8177-30C4-1C4B-9F84-8E531A85E655"), Title = "Catch Me If You Can" }, - new () { Id = Guid.Parse("4A89732D-924C-1897-F130-E267D2840DB7"), Title = "The Truman Show" }, + new () { Id = Guid.Parse("F9C0587B-EC34-4621-8002-C4A73A510C22"), Title = "Top Gun: Maverick" }, + new () { Id = Guid.Parse("6904799C-FE42-4426-8B6B-7F890FFD189E"), Title = "Spiderman: No Way Home" }, + new () { Id = Guid.Parse("EA643D21-22B7-42E0-A441-E617A718CD75"), Title = "Arrival" }, + new () { Id = Guid.Parse("26C665AF-01B0-4FE1-9201-D2ABD10A60AA"), Title = "The Revenant" }, + new () { Id = Guid.Parse("B0FB6CBD-9B68-4A8C-9025-D1B98926306D"), Title = "Birdman" }, + new () { Id = Guid.Parse("9B37EA5A-0449-46FA-9B7C-4BC3EB9AC073"), Title = "12 Years a Slave" }, + new () { Id = Guid.Parse("1BD9BECA-5ADB-4881-BB25-7DACF91E057C"), Title = "The Wolf of Wall Street" }, + new () { Id = Guid.Parse("511A3385-B6DE-4209-945E-0CF2F32A7DDE"), Title = "Her" }, + new () { Id = Guid.Parse("BFD4BF40-BCC4-479C-AEE0-4883215655AB"), Title = "The Social Network" }, + new () { Id = Guid.Parse("FC24F97C-E0B0-4E41-838C-39324E525837"), Title = "No Country for Old Men" }, + new () { Id = Guid.Parse("CB00913C-711A-4192-8F41-A6A307E4B810"), Title = "There Will Be Blood" }, + new () { Id = Guid.Parse("4CFBA432-7CB3-4CA7-947D-700AEF4D5B6D"), Title = "Pan's Labyrinth" }, + new () { Id = Guid.Parse("60037147-C57E-432E-9E0F-27D61C9E6831"), Title = "Casino Royale" }, + new () { Id = Guid.Parse("69962478-9C6C-40C3-9769-B88CC2C789DF"), Title = "Million Dollar Baby" }, + new () { Id = Guid.Parse("F0350245-6808-4D3B-B739-C6A463B1B712"), Title = "Big Fish" }, + new () { Id = Guid.Parse("5C23733B-93CE-4995-93C0-A18151EA5C34"), Title = "Catch Me If You Can" }, + new () { Id = Guid.Parse("0B770635-32C0-45D9-AC60-ED8C6083CBF5"), Title = "The Truman Show" }, new () { Id = Guid.Parse("A5074D02-536D-4573-A365-E5C8D4096054"), Title = "The Sixth Sense" }, new () {