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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 87635af..91e322f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + @@ -17,7 +18,9 @@ + + @@ -27,4 +30,4 @@ - \ No newline at end of file + diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index ea8edb3..0a9bf74 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -49,6 +49,13 @@ + + + + + + + @@ -75,12 +82,16 @@ + + + + diff --git a/README.md b/README.md index 6bbd182..18a69e4 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": { @@ -1322,6 +1334,186 @@ services `ValidateWithPortableResults` integrates with the standard options validation pipeline, supports named options, and forwards the current options name to the `ValidationContext`. Use `ValidationContext.TryGetItem(ConfigurationConstants.OptionsNameKey, out var optionsName);` to access the options name in your validator. +## OpenAPI Support + +OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult` / `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 + .AddOpenApi() + .AddPortableResultsForMinimalApis() + .AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors()); +``` + +Use `AddPortableResultsForMvc()` instead of `AddPortableResultsForMinimalApis()` for MVC applications. OpenAPI support is intentionally separate so applications that never generate OpenAPI documents do not take on the extra dependency. + +### Public surface + +Minimal APIs expose three helpers in `Light.PortableResults.AspNetCore.OpenApi`: + +- `ProducesPortableSuccessResponse(...)` +- `ProducesPortableProblem(...)` +- `ProducesPortableValidationProblem(...)` + +MVC exposes three matching attributes: + +- `[ProducesPortableSuccessResponse]` +- `[ProducesPortableProblem]` +- `[ProducesPortableValidationProblem]` + +`ProducesPortableSuccessResponse` documents both runtime success shapes: + +- Under `MetadataSerializationMode.ErrorsOnly`, the documented body is the bare `TValue`. +- Under `MetadataSerializationMode.Always`, the documented body is `{ value, metadata }`. + +Use `UseMetadataSerializationMode(...)` on Minimal APIs or the `MetadataSerializationMode = ...` named argument on the MVC attribute when the endpoint’s documented shape differs from the DI default. + +`ProducesPortableValidationProblem(...)` automatically selects the rich or ASP.NET Core-compatible validation envelope from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat`. Use `UseFormat(...)` on Minimal APIs or `Format = ...` on the MVC attribute for a per-endpoint override. + +PortableResults OpenAPI metadata is authoritative for a given `(status code, content type)` response slot. If another OpenAPI contributor already documented the same slot, the document transformer replaces that media-type schema instead of merging it. Avoid combining `ProducesPortable...` helpers or attributes with ASP.NET Core response-schema helpers for the same response slot unless you want PortableResults to win. + +### Documenting metadata + +Top-level metadata and per-error-code metadata are caller-owned contracts. The OpenAPI package documents them explicitly; the runtime still writes `MetadataObject`. + +`WithErrorCodes(...)`, endpoint-scoped `WithErrorMetadata(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 +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation.OpenApi; + +builder.Services.AddPortableResultsOpenApi( + contracts => contracts.RegisterBuiltInValidationErrors() +); +``` + +Use `ValidationErrorCodes` when opting endpoints into built-in codes. Codes such as `NotEmpty`, `LengthInRange`, and `Count` reuse global schemas from the built-in catalog: + +```csharp +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; + +app.MapPut("/api/movieRatings", AddMovieRating) + .ProducesPortableValidationProblem( + configure: x => + x.UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) + .WithInRangeError() + ); +``` + +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(...)`: + +```csharp +using Light.PortableResults.AspNetCore.OpenApi; + +builder.Services.AddPortableResultsOpenApi(contracts => +{ + contracts.ForCode("VersionMismatch"); + contracts.ForCode("InsufficientFunds"); +}); +``` + +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 +using Light.PortableResults; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; + +app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => + { + var result = await service.AddMovieRatingAsync(dto); + return result.ToMinimalApiResult(); + }) + .ProducesPortableSuccessResponse( + 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(); +``` + +The MVC equivalent uses named attribute arguments: + +```csharp +using Light.PortableResults; +using Light.PortableResults.AspNetCore.Mvc; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/movieRatings")] +public sealed class AddMovieRatingsController(AddMovieRatingService service) : ControllerBase +{ + [HttpPut] + [ProducesPortableSuccessResponse( + TopLevelMetadataType = typeof(MovieRatingResponseMetadata), + MetadataSerializationMode = MetadataSerializationMode.Always + )] + [ProducesPortableValidationProblem( + Format = ValidationProblemSerializationFormat.Rich, + ErrorCodes = new[] { "VersionMismatch" } + )] + [ProducesPortableProblem( + statusCode: StatusCodes.Status404NotFound, + TopLevelMetadataType = typeof(MovieProblemMetadata), + InlineErrorMetadataCodes = new[] { "MovieNotFound" }, + InlineErrorMetadataContracts = new[] { ErrorMetadataContract.FromType(typeof(MovieNotFoundMetadata)) } + )] + [ProducesPortableProblem] + public async Task> 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-0-openapi-support.md b/ai-plans/0040-0-openapi-support.md new file mode 100644 index 0000000..8754f33 --- /dev/null +++ b/ai-plans/0040-0-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 + +- [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 + +### 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. diff --git a/ai-plans/0040-1-openapi-redesign.md b/ai-plans/0040-1-openapi-redesign.md new file mode 100644 index 0000000..8ca5ca5 --- /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 + +- [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)` +- [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 + +### 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. 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..dadf9de --- /dev/null +++ b/ai-plans/0040-2-validation-error-contracts.md @@ -0,0 +1,188 @@ +# Built-In Validation Error Contracts for OpenAPI + +## Rationale + +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`, `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. + +Three aspects of the built-in contracts make a pure CLR-type registration awkward: + +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 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 + +- [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. +- [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"`. +- [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. + - 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`. +- [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 + +### Contract Widening + +`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, 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 pattern-matches on the contract: + +- 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. + +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 and adds one new project: + +- `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 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 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` → `{ 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 spec-version-dependent and is produced by a small helper inside `BuiltInValidationErrorContracts`: + +- 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. +} +``` + +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.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 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. 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..a83d951 --- /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 + +- [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 + +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. 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/ai-plans/0040-5-openapi-exhaustive.md b/ai-plans/0040-5-openapi-exhaustive.md new file mode 100644 index 0000000..5e4acad --- /dev/null +++ b/ai-plans/0040-5-openapi-exhaustive.md @@ -0,0 +1,118 @@ +# 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): + +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 + +- [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. + - 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. +- [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 + +### 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 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 + +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 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: + +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. 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 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..dcdef4f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", + "version": "10.0.203", "rollForward": "disable" } } diff --git a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs deleted file mode 100644 index 8b77259..0000000 --- a/samples/NativeAotMovieRating/AddMovieRating/AddMovieRatingEndpoint.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Light.PortableResults.AspNetCore.MinimalApis; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace NativeAotMovieRating.AddMovieRating; - -public static class AddMovieRatingEndpoint -{ - public static void MapAddMovieRatingEndpoint(this WebApplication app) => - app.MapPut("/api/moviesRatings", AddMovieRating); - - private static async Task AddMovieRating( - MovieRatingDto dto, - AddMovieRatingService service, - CancellationToken cancellationToken = default - ) - { - var result = await service.AddMovieRatingAsync(dto, cancellationToken); - return result.ToMinimalApiResult(); - } -} 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/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index 41e445f..58a026e 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -1,19 +1,40 @@ using System; +using System.Collections.Generic; using System.Threading; 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 Light.PortableResults.Validation.OpenApi; 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>() + .ProducesPortableValidationProblem( + configure: x => x + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes(ValidationErrorCodes.NotEmpty) + .WithErrorMetadata("MovieNotFound") + .WithInRangeError() + ); private static async Task GetMovies( IGetMoviesSession session, @@ -51,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())) } @@ -62,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/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); 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 () { diff --git a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs index 8e0d4a7..76b1e8c 100644 --- a/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs +++ b/samples/NativeAotMovieRating/JsonSerialization/MovieRatingJsonContext.cs @@ -1,14 +1,21 @@ +using System; using System.Collections.Generic; 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))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(NewMovieDto))] +[JsonSerializable(typeof(Guid?))] +[JsonSerializable(typeof(int))] public sealed partial class MovieRatingJsonContext : JsonSerializerContext; diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index 015de1b..b6b8c05 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -4,18 +4,25 @@ true true false + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated true + false - - + + + + + + + diff --git a/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs new file mode 100644 index 0000000..546c1ff --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovie/AddNewMovieEndpoint.cs @@ -0,0 +1,40 @@ +using System.Threading; +using System.Threading.Tasks; +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; +using NativeAotMovieRating.InMemoryDatabaseAccess; + +namespace NativeAotMovieRating.NewMovie; + +public static class AddNewMovieEndpoint +{ + public static void MapNewMovieEndpoint(this WebApplication app) => + app.MapPut("/api/movies", NewMovie) + .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) + .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.NotNullOrWhiteSpace) + ) + .ProducesPortableProblem(); + + private static async Task NewMovie( + 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/IAddMovieRatingSession.cs b/samples/NativeAotMovieRating/NewMovieRating/INewMovieRatingSession.cs similarity index 73% rename from samples/NativeAotMovieRating/IAddMovieRatingSession.cs rename to samples/NativeAotMovieRating/NewMovieRating/INewMovieRatingSession.cs index 011ea48..12a2460 100644 --- a/samples/NativeAotMovieRating/IAddMovieRatingSession.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/INewMovieRatingSession.cs @@ -4,9 +4,9 @@ using Light.SharedCore.DatabaseAccessAbstractions; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating; +namespace NativeAotMovieRating.NewMovieRating; -public interface IAddMovieRatingSession : ISession +public interface INewMovieRatingSession : ISession { Task GetMovieAsync(Guid movieId, CancellationToken cancellationToken = default); } diff --git a/samples/NativeAotMovieRating/InMemoryAddMovieRatingSession.cs b/samples/NativeAotMovieRating/NewMovieRating/InMemoryNewMovieRatingSession.cs similarity index 80% rename from samples/NativeAotMovieRating/InMemoryAddMovieRatingSession.cs rename to samples/NativeAotMovieRating/NewMovieRating/InMemoryNewMovieRatingSession.cs index e193767..a3bfd20 100644 --- a/samples/NativeAotMovieRating/InMemoryAddMovieRatingSession.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/InMemoryNewMovieRatingSession.cs @@ -4,13 +4,13 @@ using System.Threading.Tasks; using NativeAotMovieRating.InMemoryDatabaseAccess; -namespace NativeAotMovieRating; +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/NewMovieRating/NewMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs new file mode 100644 index 0000000..ee17cb5 --- /dev/null +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +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; + +namespace NativeAotMovieRating.NewMovieRating; + +public static class NewMovieRatingEndpoint +{ + public static void MapAddMovieRatingEndpoint(this WebApplication app) => + 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() + .ProducesPortableValidationProblem( + configure: x => x + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes( + ValidationErrorCodes.NotEmpty, + ValidationErrorCodes.LengthInRange, + ValidationErrorCodes.NotNullOrWhiteSpace + ) + .WithInRangeError() + ) + .ProducesPortableProblem(); + + private static async Task AddMovieRating( + NewMovieRatingDto dto, + NewMovieRatingService service, + CancellationToken cancellationToken = default + ) + { + var result = await service.AddMovieRatingAsync(dto, cancellationToken); + return result.ToMinimalApiResult(); + } +} 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/OpenApi/OpenApiModule.cs b/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs new file mode 100644 index 0000000..7838c23 --- /dev/null +++ b/samples/NativeAotMovieRating/OpenApi/OpenApiModule.cs @@ -0,0 +1,23 @@ +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")); + 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) => + app.MapGet("/", () => TypedResults.LocalRedirect("/docs")); +} diff --git a/samples/NativeAotMovieRating/Program.cs b/samples/NativeAotMovieRating/Program.cs index 6ba92e5..8fa4199 100644 --- a/samples/NativeAotMovieRating/Program.cs +++ b/samples/NativeAotMovieRating/Program.cs @@ -1,11 +1,15 @@ 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.AddMovieRating; using NativeAotMovieRating.GetMovies; using NativeAotMovieRating.InMemoryDatabaseAccess; using NativeAotMovieRating.JsonSerialization; +using NativeAotMovieRating.NewMovie; +using NativeAotMovieRating.NewMovieRating; +using NativeAotMovieRating.OpenApi; using Serilog; using Serilog.Events; @@ -19,18 +23,24 @@ .Services .AddPortableResultsForMinimalApis() .AddValidationForPortableResults() + .AddOpenApi() + .AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors()) .ConfigureJsonSerialization() .AddInMemoryDatabase() .AddGetMoviesModule() - .AddAddMovieRatingModule() + .AddNewMovieRatingModule() + .AddNewMovieModule() .AddHealthChecks(); var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseRouting(); -app.UseHealthChecks("/"); +app.UseHealthChecks("/health"); +app.MapOpenApiAndScalar(); app.MapGetMoviesEndpoint(); app.MapAddMovieRatingEndpoint(); +app.MapNewMovieEndpoint(); +app.RedirectHomeToDocs(); try { diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 2223bf6..cf9a6a0 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -11,17 +11,32 @@ "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, )", - "resolved": "10.0.3", - "contentHash": "H9kuN7egYzW5JAn5CMK8uJdJDVnPEDpZHzjOXyz1iAgvxNgVSoAiTNkCBeY2jaXcHlruF5fRUrZoUHUHXftDDA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + }, + "Scalar.AspNetCore": { + "type": "Direct", + "requested": "[2.14.1, )", + "resolved": "2.14.1", + "contentHash": "neS3aI7YVPDY7+I9U8nYXvSnSy6lPvGyn5w52m0MCvC5COajK4HF4vsx70OO6Fw9bd7WXOUikSBdUaLfyTzw3g==" }, "Serilog.AspNetCore": { "type": "Direct", @@ -38,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", @@ -48,6 +69,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 +147,33 @@ "light.portableresults.aspnetcore.minimalapis": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.1.0, )" + "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.1.0, )" + "Light.PortableResults": "[0.4.0, )" } }, "light.portableresults.validation": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.1.0, )" + "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": { @@ -148,6 +188,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/samples/NativeAotMovieRating/requests.http b/samples/NativeAotMovieRating/requests.http index 82332d3..d917f6f 100644 --- a/samples/NativeAotMovieRating/requests.http +++ b/samples/NativeAotMovieRating/requests.http @@ -1,5 +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/docs ### Get Movies (first page) http://localhost:5000/api/movies @@ -52,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" +} 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 2cd613c..0000000 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/PortableResultsEndpointExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Light.PortableResults.AspNetCore.Shared; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace Light.PortableResults.AspNetCore.MinimalApis; - -/// -/// Extension methods for configuring OpenAPI metadata for Light.PortableResults endpoints. -/// -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. - /// - /// The type of the result value. - /// The type of the metadata (for OpenAPI schema generation). - /// 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( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) - { - return builder.Produces>(statusCode, contentType); - } - - /// - /// Adds OpenAPI response metadata for LightSuccessResult with untyped metadata. - /// The metadata will be documented as an object with additionalProperties. - /// - /// The type of the result value. - /// 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( - this RouteHandlerBuilder builder, - int statusCode = StatusCodes.Status200OK, - string contentType = "application/json" - ) - { - return 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 60b3cf4..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.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", @@ -47,7 +47,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.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/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/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/ErrorContracts/DefaultErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultErrorMetadataContractRegistry.cs new file mode 100644 index 0000000..77a6bcc --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/DefaultErrorMetadataContractRegistry.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using Light.PortableResults.AspNetCore.OpenApi.Generation; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Default implementation of . +/// +public sealed class DefaultErrorMetadataContractRegistry : IErrorMetadataContractRegistry +{ + /// + /// Initializes a new instance of . + /// + /// The builder that holds the configured contracts. + public DefaultErrorMetadataContractRegistry(ErrorMetadataContractsBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var contracts = new Dictionary(StringComparer.Ordinal); + var sanitizedCodes = new Dictionary(StringComparer.Ordinal); + foreach (var (code, contract) in builder.Contracts) + { + if (contracts.TryGetValue(code, out var existingContract)) + { + if (existingContract.Equals(contract)) + { + continue; + } + + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingContract, + contract + ) + ); + } + + 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( + PortableResultsOpenApiMessages.CreateSanitizedErrorCodeCollisionMessage( + existingCode, + code, + sanitizedCode + ) + ); + } + + contracts.Add(code, contract); + sanitizedCodes.Add(sanitizedCode, code); + } + + Contracts = contracts.ToFrozenDictionary(); + } + + /// + 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..9046ea3 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContract.cs @@ -0,0 +1,43 @@ +using System; +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 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, + string? schemaId = null + ) + { + ArgumentNullException.ThrowIfNull(schemaFactory); + 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 new file mode 100644 index 0000000..6e184a5 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsBuilder.cs @@ -0,0 +1,98 @@ +using System; +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; + +/// +/// Builds the global map of documented error-code metadata contracts. +/// +public sealed class ErrorMetadataContractsBuilder +{ + private readonly Dictionary _contracts = new (StringComparer.Ordinal); + private readonly Dictionary _sanitizedCodes = new (StringComparer.Ordinal); + + /// + /// 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 ErrorMetadataContractsBuilder ForCode(string code) + { + return ForCode(code, typeof(TMetadata)); + } + + /// + /// Registers the specified CLR metadata type for the specified code. + /// + public ErrorMetadataContractsBuilder ForCode(string code, Type metadataType) + { + return ForCode(code, ErrorMetadataContract.FromType(metadataType)); + } + + /// + /// 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 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, + string? schemaId = null + ) + { + return ForCode(code, ErrorMetadataContract.FromSchema(metadataSchemaFactory, schemaId)); + } + + /// + /// Registers the specified code as a code that emits no metadata. + /// + public ErrorMetadataContractsBuilder ForCode(string code) + { + return ForCode(code, ErrorMetadataContract.NoMetadata); + } + + private ErrorMetadataContractsBuilder ForCode(string code, ErrorMetadataContract contract) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(contract); + + if (_contracts.TryGetValue(code, out var existingContract)) + { + if (existingContract.Equals(contract)) + { + return this; + } + + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingContract, + contract + ) + ); + } + + var sanitizedCode = PortableResultsOpenApiSchemaNaming.SanitizeErrorCode(code); + if (_sanitizedCodes.TryGetValue(sanitizedCode, out var existingRawCode)) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateSanitizedErrorCodeCollisionMessage( + existingRawCode, + code, + sanitizedCode + ) + ); + } + + _contracts.Add(code, contract); + _sanitizedCodes.Add(sanitizedCode, code); + return this; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsOptions.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsOptions.cs new file mode 100644 index 0000000..5bffaa9 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataContractsOptions.cs @@ -0,0 +1,12 @@ +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Options backing the global error-code metadata contract registry. +/// +public sealed class ErrorMetadataContractsOptions +{ + /// + /// Gets the mutable builder populated through the options pipeline. + /// + 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..9b157cd --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/ErrorMetadataSchemaContract.cs @@ -0,0 +1,75 @@ +using System; +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 schema ID that uniquely identifies this contract. When null, the ID is derived from the factory's method metadata. + public ErrorMetadataSchemaContract( + Func schemaFactory, + string? schemaId = null + ) + { + ArgumentNullException.ThrowIfNull(schemaFactory); + SchemaFactory = schemaFactory; + SchemaId = CreateSchemaId(schemaFactory, schemaId); + } + + /// + /// Gets the factory that creates a fresh metadata schema for the requested OpenAPI version. + /// + public Func SchemaFactory { get; } + + /// + /// Gets the schema ID that uniquely identifies this contract and appears in duplicate-registration errors. + /// + public string SchemaId { get; } + + /// + public override bool Equals(object? obj) => + obj is ErrorMetadataSchemaContract other && + string.Equals(SchemaId, other.SchemaId, StringComparison.Ordinal); + + /// + public override int GetHashCode() => SchemaId.GetHashCode(StringComparison.Ordinal); + + private static string CreateSchemaId( + Func schemaFactory, + string? schemaId + ) + { + if (!string.IsNullOrWhiteSpace(schemaId)) + { + 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)) + { + throw new InvalidOperationException( + "A schema-based error metadata contract requires a meaningful schema ID. " + + "Pass the schemaId 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 schema ID. " + + "Pass the schemaId 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/IErrorMetadataContractRegistry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IErrorMetadataContractRegistry.cs new file mode 100644 index 0000000..7c58a65 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ErrorContracts/IErrorMetadataContractRegistry.cs @@ -0,0 +1,14 @@ +using System.Collections.Frozen; + +namespace Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +/// +/// Provides the global map of documented error-code metadata contracts. +/// +public interface IErrorMetadataContractRegistry +{ + /// + /// Gets the immutable map of documented error codes to their metadata contracts. + /// + 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/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs new file mode 100644 index 0000000..05ba7ca --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -0,0 +1,932 @@ +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.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; +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.Generation; + +/// +/// OpenAPI document transformer for Light.PortableResults. +/// +public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocumentTransformer +{ + private readonly IErrorMetadataContractRegistry _errorMetadataContractRegistry; + private readonly PortableResultsHttpWriteOptions _writeOptions; + + /// + /// Initializes a new instance of . + /// + public PortableResultsOpenApiDocumentTransformer( + PortableResultsHttpWriteOptions writeOptions, + IErrorMetadataContractRegistry errorMetadataContractRegistry + ) + { + _writeOptions = writeOptions ?? throw new ArgumentNullException(nameof(writeOptions)); + _errorMetadataContractRegistry = errorMetadataContractRegistry ?? + throw new ArgumentNullException(nameof(errorMetadataContractRegistry)); + } + + /// + public async Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + 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; + } + + foreach (var apiDescription in context.DescriptionGroups.SelectMany(static group => group.Items)) + { + var attributes = apiDescription + .ActionDescriptor + .EndpointMetadata + .OfType() + .ToArray(); + + if (attributes.Length > 0 && TryGetOperation(document, apiDescription, out var operation)) + { + await ApplyResponseMetadataAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attributes, + cancellationToken + ); + } + } + } + + private async Task EnsureGlobalErrorContractSchemasAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + OpenApiSpecVersion openApiVersion, + 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, contract) in _errorMetadataContractRegistry.Contracts) + { + var portableErrorSchemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId( + PortableResultsOpenApiSchemas.PortableErrorSchemaId, + errorCode + ); + await EnsureCodeSpecificSchemaAsync( + document, + context, + PortableResultsOpenApiSchemas.PortableErrorSchemaId, + portableErrorSchemaId, + errorCode, + contract, + openApiVersion, + cancellationToken + ); + + var validationErrorSchemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId( + PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId, + errorCode + ); + await EnsureCodeSpecificSchemaAsync( + document, + context, + PortableResultsOpenApiSchemas.PortableValidationErrorDetailSchemaId, + validationErrorSchemaId, + errorCode, + contract, + 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) + { + // 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) + { + 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}'." + ); + } + + // "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) + { + var schema = await CreateContributingSchemaAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attribute, + cancellationToken + ); + contributingSchemas.Add(schema); + } + + var response = GetOrCreateResponse(operation, responseGroup.Key.StatusCode); + SetResponseContent( + response, + 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 + ) + { + // 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 switch + { + PortableOpenApiSuccessResponseAttributeBase successAttribute => + await CreateSuccessResponseSchemaAsync( + document, + context, + apiDescription, + operation, + successAttribute, + cancellationToken + ), + PortableOpenApiErrorResponseAttributeBase errorAttribute => + await CreateErrorResponseSchemaAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + errorAttribute, + cancellationToken + ), + _ => throw new InvalidOperationException( + $"The response attribute '{attribute.GetType().FullName}' does not expose supported OpenAPI response metadata." + ) + }; + } + + private async Task CreateSuccessResponseSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + ApiDescription apiDescription, + OpenApiOperation operation, + PortableOpenApiSuccessResponseAttributeBase attribute, + CancellationToken cancellationToken + ) + { + var metadataSerializationMode = attribute.HasMetadataSerializationModeOverride ? + attribute.MetadataSerializationMode : + _writeOptions.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( + attribute.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, + PortableOpenApiErrorResponseAttributeBase attribute, + CancellationToken cancellationToken + ) + { + ValidateInlineMetadataArrays(attribute); + + var canonicalSchemaId = ResolveCanonicalErrorEnvelopeSchemaId(attribute); + var itemBaseSchemaId = ResolveErrorItemSchemaId(canonicalSchemaId); + var documentedErrorSchema = await CreateDocumentedErrorItemSchemaAsync( + document, + context, + openApiVersion, + apiDescription, + operation, + attribute, + 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 properties = CreateCanonicalErrorEnvelopeProperties(document, canonicalSchemaId); + + if (attribute.TopLevelMetadataType is not null) + { + properties["metadata"] = await GetStableSchemaReferenceAsync( + document, + context, + attribute.TopLevelMetadataType, + PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(derivedEnvelopeSchemaId), + cancellationToken + ); + } + + if (documentedErrorSchema is not null) + { + properties[ResolveDocumentedErrorPropertyName(canonicalSchemaId)] = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = documentedErrorSchema + }; + } + + var derivedSchema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = properties, + Required = CreateCanonicalErrorEnvelopeRequired(canonicalSchemaId) + }; + 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 + ) + { + // 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 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 contract)) + { + throw new InvalidOperationException(PortableResultsOpenApiMessages.CreateUnknownErrorCodeMessage(code)); + } + + AddDocumentedCode(rawCodeContracts, code, contract); + var schemaId = PortableResultsOpenApiSchemaNaming.CreateGlobalErrorSchemaId(itemBaseSchemaId, code); + documentedVariants.Add( + new DocumentedErrorVariant( + code, + PortableResultsOpenApiSchemas.CreateSchemaReference(document, schemaId) + ) + ); + } + + var inlineCodes = attribute.InlineErrorMetadataCodes; + 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 + // 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]; + var contract = inlineContracts[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 (rawCodeContracts.TryGetValue(code, out var existingContract)) + { + if (!existingContract.Equals(contract)) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingContract, + contract + ) + ); + } + + continue; + } + + rawCodeContracts.Add(code, contract); + 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, + contract, + openApiVersion, + cancellationToken + ); + documentedVariants.Add(new DocumentedErrorVariant(code, schemaReference)); + } + } + + if (documentedVariants.Count == 0) + { + return null; + } + + 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 + { + OneOf = documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference).ToList(), + Required = new HashSet(StringComparer.Ordinal) { "code" }, + Discriminator = new OpenApiDiscriminator + { + PropertyName = "code", + Mapping = discriminatorMapping + } + }; + } + + private async Task EnsureCodeSpecificSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + string baseSchemaId, + string schemaId, + string errorCode, + ErrorMetadataContract contract, + OpenApiSpecVersion openApiVersion, + CancellationToken cancellationToken + ) + { + var schemas = EnsureSchemaStore(document); + if (!schemas.ContainsKey(schemaId)) + { + var metadataSchema = await CreateMetadataSchemaAsync( + document, + context, + schemaId, + contract, + openApiVersion, + 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 = [JsonValue.Create(errorCode)] + }; + + var extensionProperties = new Dictionary(StringComparer.Ordinal) + { + ["code"] = codeSchema + }; + if (metadataSchema is not null) + { + extensionProperties["metadata"] = metadataSchema; + } + + return new OpenApiSchema + { + AllOf = + [ + PortableResultsOpenApiSchemas.CreateSchemaReference(document, baseSchemaId), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = extensionProperties, + Required = new HashSet(StringComparer.Ordinal) { "code" } + } + ] + }; + } + + private async Task CreateMetadataSchemaAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + string ownerSchemaId, + ErrorMetadataContract contract, + OpenApiSpecVersion openApiVersion, + CancellationToken cancellationToken + ) + { + var metadataSchemaId = PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(ownerSchemaId); + return contract switch + { + ErrorMetadataTypeContract typeContract => await GetStableSchemaReferenceAsync( + document, + context, + typeContract.MetadataType, + metadataSchemaId, + cancellationToken + ), + ErrorMetadataSchemaContract schemaContract => AddComponentAndCreateReference( + document, + metadataSchemaId, + schemaContract.SchemaFactory(openApiVersion) + ), + NoMetadataContract => null, + _ => throw new InvalidOperationException( + $"The error metadata contract '{contract.GetType().FullName}' is not supported." + ) + }; + } + + 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); + schemas.TryAdd(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, + [NotNullWhen(true)] out OpenApiOperation? operation + ) + { + operation = null; + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + 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 : + HttpMethod.Parse(apiDescription.HttpMethod); + + if (httpMethod is null || + pathItem.Operations is null || + !pathItem.Operations.TryGetValue(httpMethod, out var resolvedOperation)) + { + return false; + } + + operation = resolvedOperation; + return true; + } + + private static OpenApiResponse GetOrCreateResponse(OpenApiOperation operation, int statusCode) + { + operation.Responses ??= new OpenApiResponses(); + var responseKey = statusCode.ToString(CultureInfo.InvariantCulture); + if (!operation.Responses.TryGetValue(responseKey, out var response)) + { + var createdResponse = new OpenApiResponse + { + Description = $"HTTP {statusCode}" + }; + operation.Responses.Add(responseKey, createdResponse); + return createdResponse; + } + + 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( + 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) + { + return PortableResultsOpenApiSchemas.PortableProblemDetailsSchemaId; + } + + var validationAttribute = (ProducesPortableValidationProblemAttribute) attribute; + var format = validationAttribute.HasFormatOverride ? + validationAttribute.Format : + _writeOptions.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.InlineErrorMetadataContracts is null) + { + return; + } + + if (attribute.InlineErrorMetadataCodes is null || attribute.InlineErrorMetadataContracts is null) + { + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateIncompleteInlineErrorMetadataMessage() + ); + } + + 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 contracts has length {attribute.InlineErrorMetadataContracts.Length}." + ); + } + + private static void AddDocumentedCode( + IDictionary rawCodeContracts, + string code, + ErrorMetadataContract contract + ) + { + if (rawCodeContracts.TryGetValue(code, out var existingContract)) + { + if (existingContract.Equals(contract)) + { + return; + } + + throw new InvalidOperationException( + PortableResultsOpenApiMessages.CreateDuplicateErrorMetadataContractMessage( + code, + existingContract, + 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)) + { + 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] + "}"; + } + + 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, + OpenApiSchemaReference SchemaReference + ); +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs new file mode 100644 index 0000000..e7901b8 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiMessages.cs @@ -0,0 +1,41 @@ +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +namespace Light.PortableResults.AspNetCore.OpenApi.Generation; + +internal static class PortableResultsOpenApiMessages +{ + internal static string CreateDuplicateErrorMetadataContractMessage( + string code, + ErrorMetadataContract existingContract, + ErrorMetadataContract 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, + 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 AddPortableResultsOpenApi. Register it globally or use WithErrorMetadata as an inline escape hatch."; + + internal static string CreateIncompleteInlineErrorMetadataMessage() => + "Inline error metadata must configure both InlineErrorMetadataCodes and InlineErrorMetadataContracts together."; + + private static string DescribeContract(ErrorMetadataContract contract) + { + return contract switch + { + ErrorMetadataTypeContract typeContract => typeContract.MetadataType.FullName ?? + typeContract.MetadataType.Name, + ErrorMetadataSchemaContract schemaContract => DescribeSchemaFactory(schemaContract), + NoMetadataContract => "no metadata", + _ => contract.GetType().FullName ?? contract.GetType().Name + }; + } + + private static string DescribeSchemaFactory(ErrorMetadataSchemaContract schemaContract) + => "schema factory " + schemaContract.SchemaId; +} 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/PortableOpenApiBuilderUtilities.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs new file mode 100644 index 0000000..c0a65e1 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs @@ -0,0 +1,62 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +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 ErrorMetadataContract[] AppendContracts( + ErrorMetadataContract[]? existingValues, + ErrorMetadataContract newValue + ) + { + ArgumentNullException.ThrowIfNull(newValue); + + if (existingValues is null) + { + return [newValue]; + } + + 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 new file mode 100644 index 0000000..5c0ec1a --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs @@ -0,0 +1,42 @@ +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; + +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 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. + /// + public string[]? InlineErrorMetadataCodes { get; set; } + + /// + /// Gets or sets the inline metadata contracts aligned by index with + /// . + /// + public ErrorMetadataContract[]? InlineErrorMetadataContracts { get; set; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs new file mode 100644 index 0000000..f70a773 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiResponseAttributeBase.cs @@ -0,0 +1,47 @@ +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; } +} 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/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/PortableOpenApiSuccessResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs new file mode 100644 index 0000000..cd83deb --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSuccessResponseAttributeBase.cs @@ -0,0 +1,64 @@ +using System; +using Light.PortableResults.SharedJsonSerialization; + +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 +{ + /// + /// 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 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; + set + { + field = value; + HasMetadataSerializationModeOverride = true; + } + } + + /// + /// 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 diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs new file mode 100644 index 0000000..577e939 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -0,0 +1,102 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Microsoft.OpenApi; + +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; + } + + /// + /// 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. + /// + public PortableProblemOpenApiBuilder WithErrorMetadata(string code, Type metadataType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(metadataType); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + ErrorMetadataContract.FromType(metadataType) + ); + return this; + } + + /// + /// Registers an inline metadata contract for the specified error code. + /// + 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, + string? schemaId = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(schemaFactory); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + ErrorMetadataContract.FromSchema(schemaFactory, schemaId) + ); + return this; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs new file mode 100644 index 0000000..00c2f6c --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiModule.cs @@ -0,0 +1,61 @@ +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; +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 and optional global error metadata contracts. + /// + 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))) + { + return services; + } + + services.AddSingleton(); + services.ConfigureAll( + static options => options.AddDocumentTransformer() + ); + return services; + } + + private static void RegisterErrorMetadataContractRegistry(IServiceCollection services) + { + services.TryAddSingleton( + static serviceProvider => + new DefaultErrorMetadataContractRegistry( + serviceProvider.GetRequiredService>().Value.Builder + ) + ); + } + + 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..4c06453 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableResultsOpenApiRouteHandlerBuilderExtensions.cs @@ -0,0 +1,82 @@ +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. + /// + /// + /// 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, + 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. + /// + /// + /// 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, + 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. + /// + /// + /// 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, + 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/PortableSuccessResponseOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableSuccessResponseOpenApiBuilder.cs new file mode 100644 index 0000000..8d75e8b --- /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 PortableOpenApiSuccessResponseAttributeBase _attribute; + private readonly Action _setMetadataSerializationMode; + + internal PortableSuccessResponseOpenApiBuilder( + PortableOpenApiSuccessResponseAttributeBase 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..6dd60f8 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -0,0 +1,113 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.Http.Writing; +using Microsoft.OpenApi; + +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; + } + + /// + /// 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. + /// + public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code, Type metadataType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(metadataType); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + ErrorMetadataContract.FromType(metadataType) + ); + return this; + } + + /// + /// Registers an inline metadata contract for the specified error code. + /// + public PortableValidationProblemOpenApiBuilder WithErrorMetadata(string code) + { + return WithErrorMetadata(code, typeof(TMetadata)); + } + + /// + /// Registers an inline schema-factory metadata contract for the specified error code. + /// + public PortableValidationProblemOpenApiBuilder WithErrorMetadata( + string code, + Func schemaFactory, + string? schemaId = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(schemaFactory); + + _attribute.InlineErrorMetadataCodes = PortableOpenApiBuilderUtilities.AppendStrings( + _attribute.InlineErrorMetadataCodes, + code + ); + _attribute.InlineErrorMetadataContracts = PortableOpenApiBuilderUtilities.AppendContracts( + _attribute.InlineErrorMetadataContracts, + ErrorMetadataContract.FromSchema(schemaFactory, schemaId) + ); + return this; + } + + /// + /// 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..6add119 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableProblemAttribute.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.AspNetCore.Http; + +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 +{ + /// + /// 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..0ce63d1 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableSuccessResponseAttribute.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.AspNetCore.Http; + +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 +{ + /// + /// 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(statusCode, contentType, typeof(TValue)) { } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs new file mode 100644 index 0000000..132d0eb --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/ProducesPortableValidationProblemAttribute.cs @@ -0,0 +1,45 @@ +using System; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.Http; + +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 +{ + /// + /// 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; + set + { + field = value; + HasFormatOverride = true; + } + } + + /// + /// Indicates whether the serialization format for the validation problem response has been explicitly overridden. + /// + public bool HasFormatOverride { get; private set; } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemaNaming.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemaNaming.cs new file mode 100644 index 0000000..b93ed70 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemaNaming.cs @@ -0,0 +1,155 @@ +using System; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi.Schemas; + +/// +/// Provides helpers for creating stable OpenAPI component schema ids used by Light.PortableResults. +/// +public static class PortableResultsOpenApiSchemaNaming +{ + /// + /// 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, + int statusCode, + string contentType + ) + { + var operationToken = CreateOperationToken(operation, apiDescription); + return $"{canonicalName}__{operationToken}__{statusCode}__{SanitizeSegment(contentType)}"; + } + + /// + /// 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, + int statusCode, + string contentType, + string errorCode + ) + { + var operationToken = CreateOperationToken(operation, apiDescription); + return + $"{baseSchemaName}__{operationToken}__{statusCode}__{SanitizeSegment(contentType)}__{SanitizeErrorCode(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)}"; + } + + /// + /// 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"; + } + + /// + /// 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); + } + + /// + /// Replaces characters that are unsuitable for component schema ids with underscores. + /// + /// The raw segment value. + /// The sanitized segment. + private 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); + } + + /// + /// 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); + + 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/Schemas/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs new file mode 100644 index 0000000..a8cd838 --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.OpenApi; + +namespace Light.PortableResults.AspNetCore.OpenApi.Schemas; + +/// +/// Installs the canonical Light.PortableResults OpenAPI schemas into a document. +/// +public static class PortableResultsOpenApiSchemas +{ + /// + /// 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"; + + /// + /// The component schema id for the enum values. + /// + public 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(schemas, ErrorCategorySchemaId, CreateErrorCategorySchema()); + AddIfMissing(schemas, PortableErrorSchemaId, CreatePortableErrorSchema(document)); + AddIfMissing( + schemas, + PortableValidationErrorDetailSchemaId, + CreatePortableValidationErrorDetailSchema(document) + ); + AddIfMissing(schemas, PortableProblemDetailsSchemaId, CreatePortableProblemDetailsSchema(document)); + AddIfMissing( + schemas, + PortableRichValidationProblemDetailsSchemaId, + CreatePortableRichValidationProblemDetailsSchema(document) + ); + AddIfMissing( + schemas, + PortableAspNetCoreValidationProblemDetailsSchemaId, + CreatePortableAspNetCoreValidationProblemDetailsSchema(document) + ); + } + + /// + /// Creates an open-ended metadata schema that allows arbitrary object properties. + /// + /// The metadata schema. + public static OpenApiSchema CreateOpenMetadataSchema() + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object | JsonSchemaType.Null, + AdditionalPropertiesAllowed = true + }; + } + + /// + /// 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); + } + + 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( + IDictionary schemas, + string schemaId, + OpenApiSchema schema + ) + { + schemas.TryAdd(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 = CreatePortableProblemDetailsProperties(document), + Required = CreatePortableProblemDetailsRequired() + }; + } + + private static OpenApiSchema CreatePortableRichValidationProblemDetailsSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = CreatePortableProblemDetailsProperties(document), + Required = CreatePortableProblemDetailsRequired() + }; + } + + private static OpenApiSchema CreatePortableAspNetCoreValidationProblemDetailsSchema(OpenApiDocument document) + { + return new OpenApiSchema + { + Type = JsonSchemaType.Object, + 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 = JsonSchemaType.Array, + Items = CreateSchemaReference(document, PortableErrorSchemaId) + }, + ["metadata"] = CreateOpenMetadataSchema() + }; + } + + /// + /// 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) + { + ["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() + }; + } + + /// + /// 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/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json new file mode 100644 index 0000000..7b9d65b --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/packages.lock.json @@ -0,0 +1,81 @@ +{ + "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.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.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/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/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json b/src/Light.PortableResults.AspNetCore.Shared/packages.lock.json index 3bf4acc..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.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" }, "Microsoft.SourceLink.GitHub": { "type": "Direct", diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs new file mode 100644 index 0000000..606047a --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation.Definitions; +using Microsoft.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, + _ => CreateComparisonSchema(), + $"EqualToMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific EqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateComparisonSchema(), + $"NotEqualToMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific NotEqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithNotEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateComparisonSchema(), + $"GreaterThanMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific GreaterThan validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithGreaterThanError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateComparisonSchema(), + $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithGreaterThanOrEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateComparisonSchema(), + $"LessThanMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific LessThan validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithLessThanError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateComparisonSchema(), + $"LessThanOrEqualToMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithLessThanOrEqualToError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateRangeSchema(), + $"InRangeMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific InRange validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithInRangeError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateRangeSchema(), + $"NotInRangeMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific NotInRange validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithNotInRangeError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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, + _ => CreateRangeSchema(), + $"ExclusiveRangeMetadata<{typeof(T).Name}>" + ); + + /// Documents endpoint-specific ExclusiveRange validation error metadata. + public static PortableValidationProblemOpenApiBuilder WithExclusiveRangeError( + this PortableValidationProblemOpenApiBuilder builder + ) => + 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) + { + 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..ea28d2f --- /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 ErrorMetadataContractsBuilder RegisterBuiltInValidationErrors( + this ErrorMetadataContractsBuilder builder + ) + { + ArgumentNullException.ThrowIfNull(builder); + + foreach (var (code, contract) in BuiltInValidationErrorContracts.Contracts) + { + switch (contract) + { + case ErrorMetadataTypeContract typeContract: + builder.ForCode(code, typeContract.MetadataType); + break; + case ErrorMetadataSchemaContract schemaContract: + builder.ForCode(code, schemaContract.SchemaFactory, schemaContract.SchemaId); + break; + case NoMetadataContract: + 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..fd80e14 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Frozen; +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 FrozenDictionary Contracts { get; } = CreateContracts(); + + private static FrozenDictionary CreateContracts() + { + return new Dictionary(StringComparer.Ordinal) + { + [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) + { + [ValidationErrorMetadataKeys.MinLength] = IntegerSchema(), + [ValidationErrorMetadataKeys.MaxLength] = IntegerSchema() + } + ) + ), + [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) + { + [ValidationErrorMetadataKeys.Pattern] = StringSchema(), + [ValidationErrorMetadataKeys.RegexOptions] = IntegerSchema() + } + ) + ), + [ValidationErrorCodes.Enum] = Schema( + ValidationErrorCodes.Enum, + ObjectWithString(ValidationErrorMetadataKeys.EnumType) + ), + [ValidationErrorCodes.EnumName] = Schema( + ValidationErrorCodes.EnumName, + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.EnumType] = StringSchema(), + [ValidationErrorMetadataKeys.IgnoreCase] = BooleanSchema() + } + ) + ), + [ValidationErrorCodes.PrecisionScale] = Schema( + ValidationErrorCodes.PrecisionScale, + _ => CreateObjectSchema( + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.ExpectedPrecision] = IntegerSchema(), + [ValidationErrorMetadataKeys.ExpectedScale] = IntegerSchema(), + [ValidationErrorMetadataKeys.IgnoreTrailingZeros] = BooleanSchema() + } + ) + ), + [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 ErrorMetadataContract Schema( + string code, + Func schemaFactory + ) => ErrorMetadataContract.FromSchema(schemaFactory, "built-in validation schema for " + code); + + 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/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/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.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/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/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 575be94..0000000 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/PortableResultsEndpointExtensionsTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Linq; -using FluentAssertions; -using Light.PortableResults.AspNetCore.Shared; -using Light.PortableResults.Metadata; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using Xunit; - -namespace Light.PortableResults.AspNetCore.MinimalApis.Tests; - -public sealed class PortableResultsEndpointExtensionsTests -{ - [Fact] - public void ProducesPortableResult_ShouldRegisterWrappedResponseMetadata() - { - var builder = WebApplication.CreateBuilder(); - var app = builder.Build(); - - var routeBuilder = app.MapGet("/test", () => "ok"); - var returned = routeBuilder.ProducesPortableResult(); - - 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() - { - var builder = WebApplication.CreateBuilder(); - var app = builder.Build(); - - var routeBuilder = app.MapGet("/test-metadata", () => "ok"); - var returned = routeBuilder.ProducesPortableResult(); - - 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)); - } -} 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/IntegrationTests/RegularMvcController.cs b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/RegularMvcController.cs index b2c03f2..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] - [ProducesPortableResult, Dictionary>] + [ProducesResponseType>(200)] 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/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.OpenApi.Tests/ErrorMetadataContractTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs new file mode 100644 index 0000000..3c941a2 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/ErrorMetadataContractTests.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class ErrorMetadataContractTests +{ + [Fact] + public void ContractFactories_ShouldExposeClosedSubclassPayloads() + { + Func schemaFactory = _ => new OpenApiSchema(); + + var typeContract = ErrorMetadataContract.FromType(typeof(TestMetadata)); + 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).SchemaId.Should().Be("test schema"); + noMetadata.Should().BeOfType(); + ErrorMetadataContract.NoMetadata.Should().BeSameAs(noMetadata); + typeof(ErrorMetadataContract) + .GetMember("Kind") + .Should() + .BeEmpty(); + + var discriminator = typeContract switch + { + ErrorMetadataTypeContract => "type", + ErrorMetadataSchemaContract => "schema", + NoMetadataContract => "none", + _ => "unknown" + }; + discriminator.Should().Be("type"); + } + + [Fact] + public void ContractsBuilder_ShouldBeIdempotentForEquivalentContracts() + { + Func schemaFactory = _ => new OpenApiSchema(); + var builder = new ErrorMetadataContractsBuilder(); + + builder.ForCode("TypeCode"); + builder.ForCode("TypeCode", typeof(TestMetadata)); + builder.ForCode("SchemaCode", schemaFactory, "my schema"); + builder.ForCode("SchemaCode", schemaFactory, "my schema"); + builder.ForCode("NoMetadataCode"); + builder.ForCode("NoMetadataCode"); + + var registry = new DefaultErrorMetadataContractRegistry(builder); + registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode", "SchemaCode", "NoMetadataCode"); + } + + [Fact] + public void SchemaContracts_ShouldAllowExplicitSchemaIds() + { + // ReSharper disable once ConvertToLocalFunction + Func schemaFactory = _ => new OpenApiSchema(); + + var schemaContract = ErrorMetadataContract.FromSchema(schemaFactory, "named schema"); + + schemaContract.Should().BeOfType() + .Which.SchemaId.Should().Be("named schema"); + } + + [Fact] + public void SchemaContracts_ShouldDeriveSchemaIdFromMethodInfo_WhenNullIsPassed() + { + var schemaContract = new ErrorMetadataSchemaContract(CreateSchema); + + schemaContract.SchemaId.Should().Contain(nameof(CreateSchema)); + } + + [Fact] + public void SchemaContracts_ShouldThrow_WhenNoMeaningfulSchemaIdCanBeDerived() + { + new Action(() => ErrorMetadataContract.FromSchema(_ => new OpenApiSchema())) + .Should() + .Throw() + .WithMessage("*meaningful schema ID*schemaId*"); + } + + [Fact] + public void ContractsBuilder_ShouldRejectConflictingContractsForSameCode() + { + Func firstFactory = _ => new OpenApiSchema(); + Func secondFactory = _ => new OpenApiSchema(); + + new Action( + () => new ErrorMetadataContractsBuilder() + .ForCode("Conflict") + .ForCode("Conflict") + ) + .Should() + .Throw() + .WithMessage("*Conflict*TestMetadata*OtherMetadata*"); + + new Action( + () => new ErrorMetadataContractsBuilder() + .ForCode("Conflict", firstFactory, "first schema") + .ForCode("Conflict", secondFactory, "second schema") + ) + .Should() + .Throw() + .WithMessage("*Conflict*first schema*second schema*"); + + new Action( + () => new ErrorMetadataContractsBuilder() + .ForCode("Conflict") + .ForCode("Conflict") + ) + .Should() + .Throw() + .WithMessage("*Conflict*no metadata*TestMetadata*"); + } + + [Fact] + public void ContractRegistry_ShouldExposePortableMetadataContracts() + { + var registry = new DefaultErrorMetadataContractRegistry( + new ErrorMetadataContractsBuilder().ForCode("TypeCode") + ); + + registry.Contracts.Should().BeAssignableTo>(); + registry.Contracts["TypeCode"].Should().BeOfType(); + } + + [Fact] + public void ContractRegistry_ShouldSnapshotBuilderState() + { + var builder = new ErrorMetadataContractsBuilder().ForCode("TypeCode"); + + var registry = new DefaultErrorMetadataContractRegistry(builder); + builder.ForCode("OtherCode"); + + registry.Contracts.Keys.Should().BeEquivalentTo("TypeCode"); + } + + [Fact] + public void ContractRegistry_ShouldRejectSanitizedCodeCollisions_WhenBuilderStateIsComposedExternally() + { + var builder = new ErrorMetadataContractsBuilder(); + var contractsField = typeof(ErrorMetadataContractsBuilder).GetField( + "_contracts", + BindingFlags.Instance | BindingFlags.NonPublic + ); + contractsField.Should().NotBeNull(); + + var contracts = contractsField + .GetValue(builder) + .Should() + .BeOfType>() + .Subject; + contracts.Add("Code/One", ErrorMetadataContract.NoMetadata); + contracts.Add("Code_One", ErrorMetadataContract.NoMetadata); + + var act = () => _ = new DefaultErrorMetadataContractRegistry(builder); + + act.Should().Throw().WithMessage("*Code/One*Code_One*"); + } + + [Fact] + public void NoMetadataContract_ShouldReturn0_WhenGetHashCodeIsCalled() => + ErrorMetadataContract.NoMetadata.GetHashCode().Should().Be(0); + + [Fact] + public void ErrorMetadataSchemaContract_ShouldReturnHashCodeFromSchemaId() + { + var schemaContract = new ErrorMetadataSchemaContract(CreateSchema); + + var hashCode = schemaContract.GetHashCode(); + + var expectedHashCode = schemaContract.SchemaId.GetHashCode(StringComparison.Ordinal); + hashCode.Should().Be(expectedHashCode); + } + + [Fact] + public void PortableErrorMetadataTypeContract_ShouldReturnHashCodeFromMetadataType() + { + var typeContract = new ErrorMetadataTypeContract(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 + { + 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 +} 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/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs new file mode 100644 index 0000000..ec19de2 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -0,0 +1,225 @@ +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") + .AllowUnknownErrorCodes() + .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.AllowUnknownErrorCodes.Should().BeTrue(); + attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); + attribute.InlineErrorMetadataContracts.Should().Equal( + ErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), + ErrorMetadataContract.FromType(typeof(ProblemMetadata)) + ); + } + + [Fact] + public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() + { + var attribute = GetMetadata( + static builder => + builder.ProducesPortableValidationProblem( + configure: x => x.WithErrorCodes("First") + .WithErrorCodes() + .WithErrorCodes("Second", "Third") + .AllowUnknownErrorCodes() + .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.AllowUnknownErrorCodes.Should().BeTrue(); + attribute.InlineErrorMetadataCodes.Should().Equal("Movie/Gone", "Movie/Archived"); + attribute.InlineErrorMetadataContracts.Should().Equal( + ErrorMetadataContract.FromType(typeof(InlineProblemMetadata)), + ErrorMetadataContract.FromType(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" } + }, + "Equivalent schema" + ), + 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(configureContracts); + builder.Services.Configure( + options => + { + options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; + options.ValidationProblemSerializationFormat = + ValidationProblemSerializationFormat.AspNetCoreCompatible; + } + ); + 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/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs new file mode 100644 index 0000000..cd491b5 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -0,0 +1,991 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +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.ErrorContracts; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.SharedJsonSerialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +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 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)); + 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.OneOf[1]).Should() + .Be("PortableError__Insufficient_Funds"); + globalProblemItems.Required.Should().Contain("code"); + 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)); + 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, + "/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)); + problemComponent.AllOf.Should().BeNull(); + problemComponent.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__"); + + 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] + 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_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() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/unknown", static () => TypedResults.Problem()) + .ProducesPortableProblem(configure: x => x.WithErrorCodes("UnknownCode")); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .WithMessage("*UnknownCode*AddPortableResultsOpenApi*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()); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + 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); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .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, + // ReSharper disable once RedundantArgumentDefaultValue + "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, + // ReSharper disable once RedundantArgumentDefaultValue + "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); + } + + [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(); + } + + [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)] + 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.InlineErrorMetadataContracts = + [ErrorMetadataContract.FromType(typeof(InlineProblemMetadata))]; + } + + webApplication + .MapGet($"/minimal/problems/invalid-inline-{configureCodes}", static () => TypedResults.Problem()) + .WithMetadata(attribute); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await GetOpenApiDocumentAsync(app); + + await act.Should() + .ThrowAsync() + .WithMessage("*InlineErrorMetadataCodes*InlineErrorMetadataContracts*"); + } + + [Fact] + public void ErrorMetadataContractsBuilder_ShouldRejectSanitizedCodeCollisions() + { + var builder = new ErrorMetadataContractsBuilder(); + + builder.ForCode("Code/One"); + var act = () => builder.ForCode("Code_One"); + + 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(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 = GetErrorItems(component); + + // Duplicate inline code with identical type must be deduplicated: only one documented variant remains. + items.OneOf.Should().ContainSingle(); + } + + [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_WhenConfiguredViaAddPortableResultsOpenApi() + { + var services = new ServiceCollection(); + services.AddOptions(); + + services.AddPortableResultsOpenApi( + contracts => contracts.ForCode("VersionMismatch") + ); + services.AddPortableResultsOpenApi( + contracts => contracts.ForCode("Insufficient/Funds") + ); + + services.Where(static descriptor => descriptor.ServiceType == typeof(IErrorMetadataContractRegistry)) + .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); + 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( + contracts => + { + contracts.ForCode("VersionMismatch"); + contracts.ForCode("Insufficient/Funds"); + } + ); + builder.Services.Configure( + options => + { + options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; + options.ValidationProblemSerializationFormat = + ValidationProblemSerializationFormat.AspNetCoreCompatible; + } + ); + 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(); + + configureEndpoints?.Invoke(app); + return app; + } + + private static WebApplication CreateMvcApp() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMvc(); + builder.Services.AddPortableResultsOpenApi( + contracts => contracts.ForCode("VersionMismatch") + ); + builder.Services.Configure( + options => + { + options.MetadataSerializationMode = MetadataSerializationMode.ErrorsOnly; + options.ValidationProblemSerializationFormat = + ValidationProblemSerializationFormat.AspNetCoreCompatible; + } + ); + 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 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]; + return (OpenApiResponse) operation.Responses![statusCode.ToString(CultureInfo.InvariantCulture)]; + } + + private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) + { + 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; + 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 = ["VersionMismatch"] + )] + public IActionResult GetProblem() => Problem(); + + [HttpGet("validation")] + [ProducesPortableValidationProblem( + Format = ValidationProblemSerializationFormat.Rich, + ErrorCodes = ["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; + } +} + +// ReSharper disable UnusedMember.Global - required for testing +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; +} + +// 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 new file mode 100644 index 0000000..e227703 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiSchemasTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.Schemas; +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" + } + ); + + 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(); + } + + [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(); + var secondAspNetCoreRequired = + 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"); + aspNetCoreRequired.Remove("errors"); + secondAspNetCoreRequired.Should().Contain("errors"); + } +} 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..18dbe2a --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/packages.lock.json @@ -0,0 +1,318 @@ +{ + "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, )" + } + }, + "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.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/HttpExtensionsTests.cs b/tests/Light.PortableResults.AspNetCore.Shared.Tests/HttpExtensionsTests.cs new file mode 100644 index 0000000..da42c0d --- /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().Equal("trace-42"); + } + + [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/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.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs new file mode 100644 index 0000000..77631ae --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Frozen; +using System.Linq; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Light.PortableResults.Validation.Definitions; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.Validation.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 => + [ + 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); + foreach (var code in expectedNoMetadataCodes) + { + BuiltInValidationErrorContracts.Contracts[code].Should().BeSameAs(ErrorMetadataContract.NoMetadata); + } + } + + [Fact] + public void Contracts_ShouldBeBackedByFrozenDictionary() => + BuiltInValidationErrorContracts.Contracts.Should() + .BeAssignableTo>(); + + [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 ErrorMetadataContractsBuilder(); + + builder.RegisterBuiltInValidationErrors(); + var registry = new DefaultErrorMetadataContractRegistry(builder); + + registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); + registry.Contracts.Should().NotContainKey(ValidationErrorCodes.Predicate); + } + + [Fact] + public void RegisterBuiltInValidationErrors_ShouldBeIdempotent() + { + var builder = new ErrorMetadataContractsBuilder(); + + builder.RegisterBuiltInValidationErrors(); + builder.RegisterBuiltInValidationErrors(); + var registry = new DefaultErrorMetadataContractRegistry(builder); + + registry.Contracts.Keys.Should().BeEquivalentTo(BuiltInValidationErrorContracts.Contracts.Keys); + } + + [Fact] + public void RegisterBuiltInValidationErrors_ShouldRejectConflictingPreRegisteredContracts() + { + var builder = new ErrorMetadataContractsBuilder() + .ForCode(ValidationErrorCodes.Count); + + var act = builder.RegisterBuiltInValidationErrors; + + act.Should().Throw().WithMessage("*Count*ConflictingMetadata*"); + } + + private static OpenApiSchema GetFirstPrimitiveValueSchema(string code, OpenApiSpecVersion version) + { + var contract = (ErrorMetadataSchemaContract) BuiltInValidationErrorContracts.Contracts[code]; + var schema = contract.SchemaFactory(version); + var propertyName = schema.Properties!.ContainsKey(ValidationErrorMetadataKeys.ComparativeValue) ? + ValidationErrorMetadataKeys.ComparativeValue : + ValidationErrorMetadataKeys.LowerBoundary; + return (OpenApiSchema) schema.Properties[propertyName]; + } + + // 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/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..a623e1d --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableProblemOpenApiBuilderTests.cs @@ -0,0 +1,145 @@ +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.Should().BeNull(); + responseItems.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo( + [ + "PortableError__NotEmpty", + "PortableError__LengthInRange", + "PortableError__MixedProblem__400__application_problem_json__InRange" + ] + ); + } + + [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(); + } + ); + } + ); + + // ReSharper disable once AccessToDisposedClosure -- act is called before disposal + var act = async () => await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + await act.Should().NotThrowAsync(); + } + + 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..d997ec1 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/PortableValidationProblemOpenApiBuilderTests.cs @@ -0,0 +1,217 @@ +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.Should().BeNull(); + responseItems.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo("PortableError__Count", "PortableError__NotNull"); + 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.Should().BeNull(); + responseItems.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo( + "PortableError__NotEmpty", + "PortableError__LengthInRange", + "PortableError__MixedValidation__400__application_problem_json__InRange" + ); + } + + 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..6ddc355 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiDocumentTestUtilities.cs @@ -0,0 +1,76 @@ +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(configureContracts); + builder.Services.Configure( + options => options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich + ); + 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 propertyName = component.Properties!.ContainsKey("errorDetails") ? "errorDetails" : "errors"; + return (OpenApiSchema) ((OpenApiSchema) component.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" +} 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() { 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": {