From 01b70efa42cad99bd80e81712194fe80e48bbdb4 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 2 May 2026 07:10:03 +0200 Subject: [PATCH 01/17] chore: add initial plan for OpenAPI source generators Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0042-openapi-source-generation.md | 255 +++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 ai-plans/0042-openapi-source-generation.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 0a9bf74..98e03cd 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -56,6 +56,7 @@ + diff --git a/ai-plans/0042-openapi-source-generation.md b/ai-plans/0042-openapi-source-generation.md new file mode 100644 index 0000000..66c2e10 --- /dev/null +++ b/ai-plans/0042-openapi-source-generation.md @@ -0,0 +1,255 @@ +# Validator-Driven OpenAPI Source Generation + +## Rationale + +The current OpenAPI implementation is explicit and NativeAOT-safe: endpoints opt into validation error codes through `ProducesPortableValidationProblem(...)`, built-in validation contracts are registered through `Light.PortableResults.Validation.OpenApi`, and documented error codes are exhaustive by default with `AllowUnknownErrorCodes()` as the opt-out. This gives correct schemas, but it requires endpoint authors to duplicate information that already exists in their validators. + +This plan introduces a source-generation path that lets users mark validators as OpenAPI-documentable. The generator inspects supported validation calls in `PerformValidation` / `PerformValidationAsync`, derives the same `PortableValidationProblemOpenApiBuilder` calls users write manually today, and emits reflection-free code that can be consumed by Minimal API endpoint metadata. The generator must be conservative: built-in and explicitly annotated validation rules are supported, while arbitrary delegates (`Must`) and imperative custom blocks (`Custom`) are treated as opaque unless the user supplies explicit documentation hints. + +The goal is not to infer arbitrary C# behavior. The goal is to create a stable, AOT-compatible contract model where built-in checks and user-defined rule methods expose enough compile-time metadata for the generator to produce accurate OpenAPI response schemas. + +## Acceptance Criteria + +- [ ] A new source-generator project is added for validation OpenAPI generation. It targets `netstandard2.0`, is packaged as a Roslyn analyzer, and does not add runtime dependencies to applications beyond the existing validation/OpenAPI packages. +- [ ] Public generator-facing attributes are added without violating layering: validation-rule and validation-error-contract attributes live in the validation layer and do not reference `Microsoft.OpenApi`; OpenAPI-specific opt-in attributes live in `Light.PortableResults.Validation.OpenApi`. +- [ ] Built-in validation checks and built-in validation error definitions are annotated so the generator can identify their error code, metadata shape, and method-argument-to-metadata bindings without hard-coded method-name tables. +- [ ] A public static-abstract contract interface is added in `Light.PortableResults.Validation.OpenApi`, allowing generated partial validator classes to expose `ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)` without reflection. +- [ ] A Minimal API helper such as `ProducesPortableValidationProblemFor(...)` is added. It calls the generated validator contract and then applies any caller-supplied manual builder configuration. +- [ ] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial`. The generator emits a clear diagnostic when the class is not partial or is otherwise unsupported. +- [ ] The generator supports synchronous `Validator` / `Validator` and asynchronous `AsyncValidator` / `AsyncValidator` validators by inspecting the corresponding `PerformValidation` or `PerformValidationAsync` method body. +- [ ] The generator recognizes direct `ValidationContext.Check(...)` calls and fluent chains of supported check extension methods. It supports both standalone calls and assignments that consume a `ValidatedValue` returned by a check chain. +- [ ] The generator derives built-in no-metadata and fixed-metadata errors as `WithErrorCodes(...)` calls and derives built-in typed comparison/range errors as the existing typed helper calls such as `WithInRangeError()`. +- [ ] The generator supports user-defined validation error definitions and user-defined check extension methods when they are explicitly annotated with generator-facing metadata that identifies the error code and metadata schema. +- [ ] `Must(...)`, `Predicate`, and delegate-based validation are treated as opaque unless the failing definition or wrapper method is statically documentable. Unsupported opaque flows produce diagnostics and require an explicit opt-in to unknown errors or explicit emitted-error hints. +- [ ] `Custom(...)` is treated as opaque by default because it can emit zero, one, or many errors. A documented validator that uses `Custom(...)` must either provide explicit emitted-error hints or opt into unknown errors. +- [ ] Generated code is NativeAOT-safe: it does not use reflection, does not instantiate arbitrary validation error definitions for documentation, and does not call ASP.NET Core's runtime schema exporter for generated validation metadata. +- [ ] The `NativeAotMovieRating` sample is updated to demonstrate the source-generated path: validators are partial and annotated, endpoints use the generated helper instead of manually repeating the built-in validation error codes, and the generated OpenAPI document is equivalent to the manually configured one. +- [ ] Automated tests are added for generator output, diagnostics, Minimal API integration, built-in rule coverage, user-defined rule support, opaque `Must` / `Custom` handling, and NativeAOT-safe generated code shape. +- [ ] Documentation is updated to explain the generator opt-in model, supported validation patterns, required annotations for custom rules, limitations around delegates and imperative custom validation, and how to mix generated and manual endpoint OpenAPI configuration. + +## Technical Details + +### Project Structure + +Add a new analyzer/source-generator project, tentatively `Light.PortableResults.Validation.OpenApi.SourceGeneration`, under `src/`. It should target `netstandard2.0`, reference the Roslyn packages needed for an incremental generator, and be packed as an analyzer. The generator project may reference the validation and validation-OpenAPI assemblies for symbol names and attribute definitions, but generated application code must depend only on the runtime packages already used by the application. + +Add a matching test project under `tests/`, for example `Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests`. The tests should compile in-memory source snippets with the generator, assert diagnostics, inspect generated source, and run at least a few generated validators through the existing in-memory OpenAPI document test utilities. + +The solution currently has no Roslyn source-generation infrastructure, so this plan should establish the package layout, test helpers, and central package versions for `Microsoft.CodeAnalysis.CSharp` / analyzer testing in a way that can be reused by future generators. + +### Attribute Model + +Keep the attribute model split by concern: + +- General validation-rule metadata belongs in `Light.PortableResults.Validation`. These attributes describe validation semantics in OpenAPI-agnostic terms: error code, metadata property names, metadata property value sources, and whether a rule is opaque. They must not reference `Microsoft.OpenApi`. +- OpenAPI source-generation opt-in attributes belong in `Light.PortableResults.Validation.OpenApi`. These attributes mark validators for OpenAPI generation and provide OpenAPI-specific escape hatches such as allowing unknown errors. + +The exact names can be refined during implementation, but the shape should support: + +```csharp +[GeneratePortableValidationOpenApi] +public sealed partial class NewMovieRatingValidator : Validator +{ + // existing validator implementation +} +``` + +For validation rules: + +```csharp +[ValidationRule(ValidationErrorCodes.InRange)] +[ValidationRuleMetadata( + ValidationErrorMetadataKeys.LowerBoundary, + sourceArgument: "lowerBoundary", + valueTypeSource: ValidationMetadataValueTypeSource.Argument)] +[ValidationRuleMetadata( + ValidationErrorMetadataKeys.UpperBoundary, + sourceArgument: "upperBoundary", + valueTypeSource: ValidationMetadataValueTypeSource.Argument)] +public static Check IsInBetween( + this Check check, + T lowerBoundary, + T upperBoundary, + bool shortCircuitOnError = false) +``` + +For user-defined definitions: + +```csharp +[ValidationErrorContract("MovieAlreadyRated")] +[ValidationErrorMetadataProperty("movieId", typeof(Guid))] +public sealed class MovieAlreadyRatedDefinition : ValidationErrorDefinition +{ +} +``` + +For user-defined check methods: + +```csharp +[ValidationRule("DivisibleBy", ErrorDefinitionType = typeof(DivisibleByDefinition))] +[ValidationRuleMetadata("divisor", sourceArgument: "divisor")] +public static Check IsDivisibleBy(this Check check, int divisor) +``` + +The generator should not need to instantiate `ValidationErrorDefinition` types. It should read attributes from symbols and emit OpenAPI builder calls directly. This keeps the source-generation path AOT-compatible and avoids interpreting arbitrary constructor logic. + +### Generated Contract Shape + +Add a public static-abstract interface in `Light.PortableResults.Validation.OpenApi`, for example: + +```csharp +public interface IPortableValidationOpenApiContract +{ + static abstract void ConfigurePortableValidationOpenApi( + PortableValidationProblemOpenApiBuilder builder); +} +``` + +For each marked partial validator, the generator emits another partial declaration that implements the interface and configures the builder: + +```csharp +public sealed partial class NewMovieRatingValidator : IPortableValidationOpenApiContract +{ + public static void ConfigurePortableValidationOpenApi( + PortableValidationProblemOpenApiBuilder builder) + { + builder.WithErrorCodes( + ValidationErrorCodes.NotEmpty, + ValidationErrorCodes.LengthInRange, + ValidationErrorCodes.NotNullOrWhiteSpace); + + builder.WithInRangeError(); + } +} +``` + +The generated method should be deterministic, stable across builds, and idempotent with existing builder semantics. It should group simple built-in codes into as few `WithErrorCodes(...)` calls as practical and emit typed helper calls for typed comparison/range rules. + +### Endpoint Integration + +Add a Minimal API helper in `Light.PortableResults.Validation.OpenApi`: + +```csharp +public static RouteHandlerBuilder ProducesPortableValidationProblemFor( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = PortableResultsContentTypes.ApplicationProblemJson, + Action? configure = null) + where TValidator : IPortableValidationOpenApiContract +``` + +The helper wraps the existing `ProducesPortableValidationProblem(...)` helper. It first calls `TValidator.ConfigurePortableValidationOpenApi(openApiBuilder)` and then invokes the caller-provided `configure` callback so endpoint code can still set the serialization format, top-level metadata, `AllowUnknownErrorCodes()`, or additional manual error metadata. + +Example usage: + +```csharp +app.MapPut("/api/moviesRatings", AddMovieRating) + .ProducesPortableValidationProblemFor( + configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich)); +``` + +The plan focuses on Minimal APIs first because the current sample and primary OpenAPI workflow use Minimal APIs. MVC support can be added later with either generated static contracts consumed by attributes or a separate MVC-specific plan. + +### Validator Analysis Scope + +The generator should inspect the body of `PerformValidation` / `PerformValidationAsync` on marked validators and recognize common validation patterns: + +- `context.Check(dto.Property).IsNotEmpty();` +- `dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000);` +- fluent chains such as `context.Check(dto.Name).IsNotNullOrWhiteSpace().HasLengthIn(1, 100);` +- explicit target overloads when the target argument is statically understandable + +The first implementation should intentionally stay conservative. It does not need to support arbitrary data flow, loops, complex local aliases, collection item validation, or validators invoked indirectly through helper methods unless those helper methods are themselves annotated as validation rules. Unsupported patterns should produce diagnostics that tell the user how to fix the documentation path: annotate a wrapper method, provide explicit emitted-error hints, or opt into unknown errors. + +The generator does not need target information to produce response schemas. Target inference is useful for future generated examples, but schema generation can initially focus on error codes and metadata shapes. If target information is collected, it should be treated as best-effort and should not be required for a schema to be generated. + +### Built-In Rule Coverage + +Annotate the built-in check extension methods rather than hard-coding method names in the generator. This keeps the generator extensible and gives user-defined check methods the same path as built-ins. + +Built-in rule annotations should cover: + +- no-metadata codes: `NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly` +- fixed metadata codes: `Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale` +- typed comparison/range codes: `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange` + +For fixed metadata codes, generated endpoint configuration can use `WithErrorCodes(...)` because global registration via `RegisterBuiltInValidationErrors()` already supplies the metadata schema. For typed comparison/range codes, generated endpoint configuration should use the existing typed helper methods (`WithInRangeError()`, etc.) because the endpoint pins down the concrete `T`. + +### User-Defined Rules + +User-defined rules should follow the same model as built-ins: + +1. The error definition declares its stable code and metadata properties through attributes. +2. The check extension method declares which error definition it emits and how method arguments map to metadata properties. +3. The generator reads those attributes and emits either `WithErrorCodes(...)` when a registered global contract is enough, or an inline schema-factory metadata contract when endpoint-specific type narrowing is required. + +Generated inline schema factories should be authored with `OpenApiSchema` construction and `PortableOpenApiSchemaTypeMapper`, not with reflection or `JsonSchemaExporter`. If a user-defined metadata property uses an unsupported complex type, the generator should either fall back to an unconstrained `OpenApiSchema` for that property with a diagnostic, or require the user to provide a custom schema hint attribute. + +### `Must`, `Predicate`, and `Custom` + +Delegate-based validation is not generally analyzable and must be treated as a boundary: + +- `Must(predicate)` with no explicit definition remains opaque because the built-in `Predicate` code intentionally has no stable global metadata contract. +- `Must(predicate, definition)` is documentable only when the supplied definition is statically resolvable and its type has a validation error contract attribute. +- `Must(predicate, overrides)` is documentable only when the override code and metadata shape are explicitly declared through a documentation hint; arbitrary `MetadataObject` construction should not be interpreted. +- `Custom(...)` is opaque by default because the delegate can add zero, one, or many errors to the context. + +Provide explicit opt-ins for opaque flows: + +- A validator-level or method-level attribute that tells the generator to call `AllowUnknownErrorCodes()`. +- One or more emitted-error hint attributes that explicitly list codes and metadata shapes emitted by custom validation. +- The recommended pattern for reusable predicates is an annotated wrapper check method, e.g. `IsValidSlug(...)`, rather than raw `Must(...)` calls in validators. + +Diagnostics should be helpful but not hostile. Opaque flows in a marked validator should produce warnings by default, and generated code should either omit those flows or call `AllowUnknownErrorCodes()` only when the user explicitly opted into that behavior. The generator should not silently weaken an exhaustive schema. + +### NativeAOT and Performance + +Generated code must be startup-only metadata code and must not affect the runtime validation hot path. The generator itself runs at build time; the emitted code runs only when endpoints are mapped and OpenAPI metadata is attached. + +NativeAOT requirements: + +- no reflection over validators or definitions at runtime +- no generated use of `Type.GetType`, constructor activation, or scanning assemblies +- no schema generation through `JsonSchemaExporter` +- no generated dependency on serializer metadata for generated validation metadata schemas +- generated schema factories create fresh `OpenApiSchema` instances to avoid mutable schema reuse + +The source generator should be incremental and symbol-based. It should avoid whole-compilation scans where possible by filtering on the opt-in validator attribute and validation-rule attributes. + +### Tests + +Tests should cover both generator behavior and integrated OpenAPI output: + +- generated code for a validator similar to `NewMovieRatingValidator` +- diagnostics for non-partial marked validators +- diagnostics for unsupported `Must` / `Custom` patterns +- built-in typed helper generation for `IsInBetween(1, 5)` and similar comparison/range checks +- fixed metadata built-ins such as `HasLengthIn(10, 1000)` +- user-defined check methods with annotated error definitions and metadata mappings +- explicit unknown-error opt-in producing `AllowUnknownErrorCodes()` +- generated Minimal API endpoint metadata producing the same `oneOf` / metadata schemas as manual builder calls +- a NativeAOT-oriented generated source assertion that no reflection/schema-exporter APIs appear in generated code + +Snapshot tests are appropriate for generated source, but the tests should also compile generated output and assert the resulting OpenAPI document so the generator cannot drift from the transformer contract. + +### Documentation and Sample + +Update the README and the NativeAOT sample to show the intended user-facing workflow: + +```csharp +[GeneratePortableValidationOpenApi] +public sealed partial class NewMovieRatingValidator : Validator +{ + // existing PerformValidation implementation +} + +app.MapPut("/api/moviesRatings", AddMovieRating) + .ProducesPortableValidationProblemFor( + configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich)); +``` + +The docs should clearly explain the supported subset and the escape hatches. The most important message is that source generation is precise when validation rules are explicit and annotated, while arbitrary delegates and imperative custom validation require explicit documentation hints. + +### Scope Boundaries + +This plan does not attempt to infer arbitrary C# semantics or execute validators at build time. It does not generate examples yet, although the manifest model should leave room for examples later. It does not replace the manual `ProducesPortableValidationProblem(...)` builder APIs; generated contracts compose with them. It does not require every validator in an application to opt in. It does not change runtime validation behavior or the wire format. From 3fb53b52e46d744b72db9f3a0025fac61edcd940 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 2 May 2026 11:21:34 +0200 Subject: [PATCH 02/17] chore: refine OpenAPI source generator plan Tighten scope to Minimal APIs and sync validators only, promote response-level examples and light target inference to first-class features, add bridge API extensions for example emission, and clarify diagnostics, NativeAOT constraints, and incremental-pipeline guidance. Co-Authored-By: Claude Opus 4.7 --- ai-plans/0042-openapi-source-generation.md | 193 +++++++++++++++++---- 1 file changed, 155 insertions(+), 38 deletions(-) diff --git a/ai-plans/0042-openapi-source-generation.md b/ai-plans/0042-openapi-source-generation.md index 66c2e10..b19e9d7 100644 --- a/ai-plans/0042-openapi-source-generation.md +++ b/ai-plans/0042-openapi-source-generation.md @@ -2,40 +2,57 @@ ## Rationale -The current OpenAPI implementation is explicit and NativeAOT-safe: endpoints opt into validation error codes through `ProducesPortableValidationProblem(...)`, built-in validation contracts are registered through `Light.PortableResults.Validation.OpenApi`, and documented error codes are exhaustive by default with `AllowUnknownErrorCodes()` as the opt-out. This gives correct schemas, but it requires endpoint authors to duplicate information that already exists in their validators. +The current OpenAPI implementation requires endpoint authors to repeat error-code information that already exists in their validators. This plan adds a source-generation path that derives that information from the validator and surfaces it as Minimal API endpoint metadata. -This plan introduces a source-generation path that lets users mark validators as OpenAPI-documentable. The generator inspects supported validation calls in `PerformValidation` / `PerformValidationAsync`, derives the same `PortableValidationProblemOpenApiBuilder` calls users write manually today, and emits reflection-free code that can be consumed by Minimal API endpoint metadata. The generator must be conservative: built-in and explicitly annotated validation rules are supported, while arbitrary delegates (`Must`) and imperative custom blocks (`Custom`) are treated as opaque unless the user supplies explicit documentation hints. +The scope is intentionally narrow: -The goal is not to infer arbitrary C# behavior. The goal is to create a stable, AOT-compatible contract model where built-in checks and user-defined rule methods expose enough compile-time metadata for the generator to produce accurate OpenAPI response schemas. +- **Minimal APIs only.** MVC integration is a follow-up. +- **Synchronous validators only.** `Validator` and `Validator` are supported; `AsyncValidator` and `PerformValidationAsync` are deferred. +- **Top-level statements only.** Within `PerformValidation`, anything nested inside control-flow constructs (`if`, `switch`, `foreach`, etc.) is skipped with a diagnostic. +- **Annotated rules only.** Built-in and explicitly annotated check methods are recognized; `Must`, `Predicate`, `Custom`, and `ErrorOverrides`-driven overrides are opaque unless the user supplies explicit hints. + +Examples are promoted to a first-class output. The generator inspects each call site, verifies that metadata-bound arguments are compile-time constants, and emits those values as response-level examples on the affected endpoints (`OpenApiResponse.Content[mt].Examples`). Schema-level examples remain reserved for envelope-wide constants like `category: "Validation"`. This materially improves the rendered Scalar and Swagger UI output without any extra annotation effort beyond the rule attributes themselves. + +Two pre-1.0 alignment items in `Light.PortableResults.Validation` are folded into this plan: the comparative-range methods rename from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` so method names match `ValidationErrorCodes.InRange` / `NotInRange`; and the recognized signatures stay on paired primitive arguments rather than a `Range` struct, because paired arguments resolve cleanly through `SemanticModel.GetConstantValue(...)` while a struct would force builder-chain pattern matching or cross-file symbol following. ## Acceptance Criteria -- [ ] A new source-generator project is added for validation OpenAPI generation. It targets `netstandard2.0`, is packaged as a Roslyn analyzer, and does not add runtime dependencies to applications beyond the existing validation/OpenAPI packages. +- [ ] A new source-generator project is added for validation OpenAPI generation. It targets **`netstandard2.0`** because Roslyn analyzers and source generators are loaded into the C# compiler host and must comply with that contract; this is a hard requirement, not a style choice. The project is packaged as a Roslyn analyzer, uses `LangVersion=latest` (with `PolySharp` or hand-written shims for newer language features as needed), and does not add runtime dependencies to applications beyond the existing validation/OpenAPI packages. +- [ ] The generator is implemented as an `IIncrementalGenerator` and uses `ForAttributeWithMetadataName(...)` to discover marked validators, so unrelated source changes do not invalidate the generation pipeline. Every value flowing through the pipeline implements value-equality (records or `EquatableArray`) so cached steps remain shareable. +- [ ] The analysis pass is structured as a pure function from `(Compilation, INamedTypeSymbol)` to a plain DTO describing the generated builder calls. The `IIncrementalGenerator` glue is a thin adapter on top, so the analysis is testable directly without driving the full generator harness and reusable for a future MVC analyzer. - [ ] Public generator-facing attributes are added without violating layering: validation-rule and validation-error-contract attributes live in the validation layer and do not reference `Microsoft.OpenApi`; OpenAPI-specific opt-in attributes live in `Light.PortableResults.Validation.OpenApi`. - [ ] Built-in validation checks and built-in validation error definitions are annotated so the generator can identify their error code, metadata shape, and method-argument-to-metadata bindings without hard-coded method-name tables. +- [ ] The comparative-range check methods are renamed from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` so method names match `ValidationErrorCodes.InRange` / `NotInRange`. All callers in the solution (sample, tests, internal usages) are updated. This is a breaking change accepted pre-1.0. - [ ] A public static-abstract contract interface is added in `Light.PortableResults.Validation.OpenApi`, allowing generated partial validator classes to expose `ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)` without reflection. - [ ] A Minimal API helper such as `ProducesPortableValidationProblemFor(...)` is added. It calls the generated validator contract and then applies any caller-supplied manual builder configuration. -- [ ] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial`. The generator emits a clear diagnostic when the class is not partial or is otherwise unsupported. -- [ ] The generator supports synchronous `Validator` / `Validator` and asynchronous `AsyncValidator` / `AsyncValidator` validators by inspecting the corresponding `PerformValidation` or `PerformValidationAsync` method body. +- [ ] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial` and every enclosing type to be `partial`. The generator emits clear diagnostics when the class or any enclosing type is not partial, when the validator is an open generic, or when the validator is otherwise unsupported. +- [ ] The generator supports synchronous `Validator` and `Validator` by inspecting the `PerformValidation` method body. `AsyncValidator` / `AsyncValidator` are explicitly out of scope for this plan; marking an async validator with the OpenAPI generation attribute produces a diagnostic that points at the synchronous-validator restriction. - [ ] The generator recognizes direct `ValidationContext.Check(...)` calls and fluent chains of supported check extension methods. It supports both standalone calls and assignments that consume a `ValidatedValue` returned by a check chain. +- [ ] The generator analyzes only **top-level statements** in the validation method body. Check expressions nested inside `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambdas, or local functions are skipped for schema generation and produce an info-level diagnostic that suggests lifting the check out of the control-flow construct or accepting the gap via `AllowUnknownErrorCodes()`. Local variable declarations and the trailing `checkpoint.ToValidatedValue(...)` are silently allowed. - [ ] The generator derives built-in no-metadata and fixed-metadata errors as `WithErrorCodes(...)` calls and derives built-in typed comparison/range errors as the existing typed helper calls such as `WithInRangeError()`. - [ ] The generator supports user-defined validation error definitions and user-defined check extension methods when they are explicitly annotated with generator-facing metadata that identifies the error code and metadata schema. +- [ ] Constant-valued arguments reach the generated OpenAPI documents as concrete response-level examples. The generator inspects each metadata-bound argument with `SemanticModel.GetConstantValue(...)`. When the value resolves to a constant, it flows into the endpoint's response example; when it does not, no example is emitted for that endpoint and no diagnostic is raised. The validation OpenAPI bridge supplies envelope-level constants (e.g. `category: "Validation"`) once at registration time. +- [ ] The `Light.PortableResults.Validation.OpenApi` package is extended with the public APIs needed to attach response-level examples: optional example parameters on the existing typed helpers (`WithInRangeError`, `WithGreaterThanError`, etc.), a general-purpose `WithErrorExample(...)` helper for codes without typed shortcuts, and document-transformer support that composes endpoint-declared examples into `OpenApiResponse.Content[mediaType].Examples` as a `JsonNode` tree matching the active `ValidationProblemSerializationFormat`. `RegisterBuiltInValidationErrors()` attaches the envelope-level `category: "Validation"` schema example with an override hook for users who change the default category. - [ ] `Must(...)`, `Predicate`, and delegate-based validation are treated as opaque unless the failing definition or wrapper method is statically documentable. Unsupported opaque flows produce diagnostics and require an explicit opt-in to unknown errors or explicit emitted-error hints. - [ ] `Custom(...)` is treated as opaque by default because it can emit zero, one, or many errors. A documented validator that uses `Custom(...)` must either provide explicit emitted-error hints or opt into unknown errors. - [ ] Generated code is NativeAOT-safe: it does not use reflection, does not instantiate arbitrary validation error definitions for documentation, and does not call ASP.NET Core's runtime schema exporter for generated validation metadata. -- [ ] The `NativeAotMovieRating` sample is updated to demonstrate the source-generated path: validators are partial and annotated, endpoints use the generated helper instead of manually repeating the built-in validation error codes, and the generated OpenAPI document is equivalent to the manually configured one. -- [ ] Automated tests are added for generator output, diagnostics, Minimal API integration, built-in rule coverage, user-defined rule support, opaque `Must` / `Custom` handling, and NativeAOT-safe generated code shape. -- [ ] Documentation is updated to explain the generator opt-in model, supported validation patterns, required annotations for custom rules, limitations around delegates and imperative custom validation, and how to mix generated and manual endpoint OpenAPI configuration. +- [ ] The `NativeAotMovieRating` sample is updated end to end: `NewMovieRatingValidator` becomes `partial` and is annotated with `[GeneratePortableValidationOpenApi]`; `NewMovieRatingEndpoint` replaces its manual `WithErrorCodes(...)` / `WithInRangeError()` configuration with `ProducesPortableValidationProblemFor(...)`; an automated test asserts that the resulting OpenAPI document preserves everything the manually configured document had — schemas, error codes, `oneOf` structure, and exhaustiveness flags — after canonical key ordering, while the new document is *additionally* allowed to carry response-level examples that the baseline lacks (the examples are the point of the swap, not a regression); and the rendered Scalar (`/docs`) and Swagger UI (`/swagger/index.html`) outputs both show concrete example values for at least the `InRange` and `LengthInRange` error metadata. +- [ ] Automated tests are added for generator output, diagnostics, Minimal API integration, built-in rule coverage, user-defined rule support, control-flow-skipped checks, constant-vs-non-constant example handling, opaque `Must` / `Custom` handling, and NativeAOT-safe generated code shape. Snapshot tests cover emitted source; document-generation tests cover the resulting OpenAPI document so the generator cannot drift from the transformer contract. +- [ ] Documentation is updated to explain the generator opt-in model, supported validation patterns, the top-level-statements-only analysis boundary, required annotations for custom rules, the constant-argument requirement for examples, limitations around delegates and imperative custom validation, and how to mix generated and manual endpoint OpenAPI configuration. ## Technical Details ### Project Structure -Add a new analyzer/source-generator project, tentatively `Light.PortableResults.Validation.OpenApi.SourceGeneration`, under `src/`. It should target `netstandard2.0`, reference the Roslyn packages needed for an incremental generator, and be packed as an analyzer. The generator project may reference the validation and validation-OpenAPI assemblies for symbol names and attribute definitions, but generated application code must depend only on the runtime packages already used by the application. +Add a new source-generator project, `Light.PortableResults.Validation.OpenApi.SourceGeneration`, under `src/`. It targets `netstandard2.0` because Roslyn loads analyzer/generator assemblies into the C# compiler host (csc, OmniSharp, the IDE language services) and that host requires `netstandard2.0`. Targeting `net10.0` would either fail to load in many tooling scenarios or trigger `RS1041`. Use `LangVersion=latest` so the generator source itself can use modern C# features; supply `PolySharp` or hand-rolled shims for any BCL types that are not present in `netstandard2.0` (e.g. `IsExternalInit` for records). The csproj sets `false` and references only the Roslyn packages needed for an incremental generator. The generator emits no runtime helpers — generated application code depends only on the runtime packages already used by the application. + +**Packaging:** the generator does not ship as its own NuGet. It is bundled into the existing `Light.PortableResults.Validation.OpenApi` package as an analyzer asset: the runtime DLL goes to `lib/`, the generator DLL goes to `analyzers/dotnet/cs/` in the same `.nupkg`. This is the standard pattern for "library + companion generator" (Refit, Mapperly, MediatR.SourceGenerator) and avoids the "I installed the bridge but the generator doesn't run" footgun. The bundling is wired through a `` from `Light.PortableResults.Validation.OpenApi.csproj` to the generator project, plus `` entries for packing. -Add a matching test project under `tests/`, for example `Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests`. The tests should compile in-memory source snippets with the generator, assert diagnostics, inspect generated source, and run at least a few generated validators through the existing in-memory OpenAPI document test utilities. +The generator project may reference the validation and validation-OpenAPI assemblies at design time for symbol names and attribute definitions, but it must not redistribute those assemblies — the consumer's own references provide them at compile time. -The solution currently has no Roslyn source-generation infrastructure, so this plan should establish the package layout, test helpers, and central package versions for `Microsoft.CodeAnalysis.CSharp` / analyzer testing in a way that can be reused by future generators. +Add a matching test project under `tests/`, for example `Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests`. The tests should compile in-memory source snippets with the generator, assert diagnostics, inspect generated source (snapshot tests using `Verify.SourceGenerators` are appropriate), and run at least a few generated validators through the existing in-memory OpenAPI document test utilities. Tests should also cover the analysis pass directly by calling the pure analysis function with a parsed `Compilation` and asserting the DTO it returns, independent of the generator driver. + +The solution currently has no Roslyn source-generation infrastructure, so this plan should establish the package layout, test helpers, and central package versions for `Microsoft.CodeAnalysis.CSharp`, `Microsoft.CodeAnalysis.Analyzers`, `Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing`, and `Verify.SourceGenerators` in a way that can be reused by future generators. Pin a Roslyn version that is widely available across SDKs (avoid pinning to the bleeding edge — generators must run inside the user's chosen SDK). ### Attribute Model @@ -60,19 +77,21 @@ For validation rules: [ValidationRule(ValidationErrorCodes.InRange)] [ValidationRuleMetadata( ValidationErrorMetadataKeys.LowerBoundary, - sourceArgument: "lowerBoundary", - valueTypeSource: ValidationMetadataValueTypeSource.Argument)] + sourceArgument: "lowerBoundary")] [ValidationRuleMetadata( ValidationErrorMetadataKeys.UpperBoundary, - sourceArgument: "upperBoundary", - valueTypeSource: ValidationMetadataValueTypeSource.Argument)] -public static Check IsInBetween( + sourceArgument: "upperBoundary")] +public static Check IsInRange( this Check check, T lowerBoundary, T upperBoundary, bool shortCircuitOnError = false) ``` +`ValidationRuleMetadataAttribute` supports two forms for resolving a value at generation time: `sourceArgument` for values pulled from a call-site argument by parameter name, and `constantValue` for fixed values that do not depend on the call site. Most built-in rules use `sourceArgument` exclusively; `constantValue` exists for user-defined rules whose definition pins a metadata property to a fixed value. The generator resolves `sourceArgument` values via `SemanticModel.GetConstantValue(...)` on the matching `ArgumentSyntax`; when the value is constant, it flows into the response example. + +Note that `Error.Category` is a top-level property on the error envelope, not a metadata key. All validation rules produce `ErrorCategory.Validation` by default; users can still override the category at registration or call site. The example value `"Validation"` for the envelope's `category` schema is attached centrally by the validation OpenAPI bridge in `RegisterBuiltInValidationErrors()` and does not appear on per-rule annotations. A user who overrides the category gets that override reflected in the generated example. + For user-defined definitions: ```csharp @@ -123,7 +142,7 @@ public sealed partial class NewMovieRatingValidator : IPortableValidationOpenApi } ``` -The generated method should be deterministic, stable across builds, and idempotent with existing builder semantics. It should group simple built-in codes into as few `WithErrorCodes(...)` calls as practical and emit typed helper calls for typed comparison/range rules. +The generated method should be deterministic, stable across builds, and idempotent with existing builder semantics. It should group simple built-in codes into as few `WithErrorCodes(...)` calls as practical and emit typed helper calls for typed comparison/range rules. The same error code emitted from multiple call sites in the validator is deduplicated: the example above shows `IsNotEmpty` called twice (on `dto.Id` and `dto.MovieId`) producing a single `NotEmpty` entry. Deduplication is by code string, not by call site or target. ### Endpoint Integration @@ -152,16 +171,29 @@ The plan focuses on Minimal APIs first because the current sample and primary Op ### Validator Analysis Scope -The generator should inspect the body of `PerformValidation` / `PerformValidationAsync` on marked validators and recognize common validation patterns: +The generator inspects the body of `PerformValidation` declared directly on the marked validator. It does not chase virtual chains: if the marked class inherits a `PerformValidation` from a base validator without overriding it, the generator emits an error diagnostic asking the user to declare (or re-declare) `PerformValidation` on the marked class itself. This keeps the analyzed source local to one file and avoids cross-class symbol traversal that fights Roslyn incrementality. Most validators in practice are sealed and override directly, so this restriction is not expected to bite. + +Within the analyzed method, the generator recognizes a deliberately narrow set of patterns. The boundary is **top-level statements only**: + +- expression statements of the form `context.Check(...).Assertion1(...).Assertion2(...)...AssertionN(...);` +- assignment statements of the form `target = context.Check(...).Assertion1(...)...AssertionN(...);`, where the right-hand side returns a `Check` or `ValidatedValue` +- local variable declarations whose initializer is a check chain +- the trailing `return checkpoint.ToValidatedValue(...);` +- local variable declarations and trivial expression statements that contain no check chain (these are silently allowed) + +Anything nested inside an `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambda body, or local function body is **not analyzed for schema generation**. The generator emits a single info-level diagnostic per skipped chain that points at the call site and explains the supported workarounds: lift the check out of the control-flow construct so it runs unconditionally, or accept the missing code on the documented schema by opting the endpoint into `AllowUnknownErrorCodes()`. This is not "best-effort" control-flow analysis — it is a hard, easily-explained boundary that matches the shape of every validator in the existing sample. + +Within an analyzed chain, the generator walks each invocation, looks up `[ValidationRule]` and `[ValidationRuleMetadata]` attributes on the called method, and records: -- `context.Check(dto.Property).IsNotEmpty();` -- `dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000);` -- fluent chains such as `context.Check(dto.Name).IsNotNullOrWhiteSpace().HasLengthIn(1, 100);` -- explicit target overloads when the target argument is statically understandable +- the error code emitted by the rule +- each metadata property name, its source (call-site argument or fixed constant), and — when constant — the resolved value +- whether the rule contributes a typed comparison/range error that needs a `WithInRangeError` shape rather than a `WithErrorCodes(...)` entry -The first implementation should intentionally stay conservative. It does not need to support arbitrary data flow, loops, complex local aliases, collection item validation, or validators invoked indirectly through helper methods unless those helper methods are themselves annotated as validation rules. Unsupported patterns should produce diagnostics that tell the user how to fix the documentation path: annotate a wrapper method, provide explicit emitted-error hints, or opt into unknown errors. +The generator infers the example's `target` string for the simple, predictable case: when the chain's leading expression is `context.Check(dto.SomeProperty)` and the argument is a direct member access on the validated source, the target is derived from the accessed property's name. JSON casing follows the configured `JsonNamingPolicy` (camelCase by default in modern ASP.NET Core) and respects `[JsonPropertyName]` if present on the source property. Anything more complex — chained member access (`dto.Sub.Property`), indexer access, method calls, computed expressions — produces a `null` target in the example, which is a documented limitation rather than a diagnostic. This narrow inference is enough to close most of the rendering-quality gap in Scalar and Swagger UI without dragging in real data-flow analysis. -The generator does not need target information to produce response schemas. Target inference is useful for future generated examples, but schema generation can initially focus on error codes and metadata shapes. If target information is collected, it should be treated as best-effort and should not be required for a schema to be generated. +User-defined helper methods are recognized only when annotated themselves with `[ValidationRule(...)]`. The generator does not chase unannotated helpers across files. + +Edge case: a bare `context.Check(dto.Property);` call with no subsequent assertion contributes no error codes. The generator silently ignores such calls — they are not malformed, just incomplete validation. No diagnostic is emitted. ### Built-In Rule Coverage @@ -173,8 +205,12 @@ Built-in rule annotations should cover: - fixed metadata codes: `Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale` - typed comparison/range codes: `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange` +The recognized signatures keep paired primitive arguments (`IsInRange(min, max)`, `IsGreaterThan(value)`, etc.) so the generator can resolve metadata values via `SemanticModel.GetConstantValue(...)` on each `ArgumentSyntax`. A `Range` struct is intentionally not introduced here: it would force the generator to either pattern-match builder chains or follow stored field symbols across files, both of which fight Roslyn incrementality and complicate constant-value verification. Ergonomic `Range` overloads can be added in the future as separate, generator-unrecognized overloads. + For fixed metadata codes, generated endpoint configuration can use `WithErrorCodes(...)` because global registration via `RegisterBuiltInValidationErrors()` already supplies the metadata schema. For typed comparison/range codes, generated endpoint configuration should use the existing typed helper methods (`WithInRangeError()`, etc.) because the endpoint pins down the concrete `T`. +Renaming `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` happens in the same change set as adding the rule annotations, so the generator-facing method name, the `[ValidationRule(ValidationErrorCodes.InRange)]` attribute, the error code constant, and the typed helper `WithInRangeError()` all read consistently. Existing tests, the sample, and any documentation in the repo are updated accordingly. + ### User-Defined Rules User-defined rules should follow the same model as built-ins: @@ -183,16 +219,50 @@ User-defined rules should follow the same model as built-ins: 2. The check extension method declares which error definition it emits and how method arguments map to metadata properties. 3. The generator reads those attributes and emits either `WithErrorCodes(...)` when a registered global contract is enough, or an inline schema-factory metadata contract when endpoint-specific type narrowing is required. -Generated inline schema factories should be authored with `OpenApiSchema` construction and `PortableOpenApiSchemaTypeMapper`, not with reflection or `JsonSchemaExporter`. If a user-defined metadata property uses an unsupported complex type, the generator should either fall back to an unconstrained `OpenApiSchema` for that property with a diagnostic, or require the user to provide a custom schema hint attribute. +Generated inline schema factories should be authored with `OpenApiSchema` construction and `PortableOpenApiSchemaTypeMapper`, not with reflection or `JsonSchemaExporter`. If a user-defined metadata property uses an unsupported complex type, the generator falls back to an unconstrained `OpenApiSchema` for that property and emits a warning diagnostic. A future iteration may add an explicit schema-hint attribute for opt-in narrowing of those properties; this plan does not introduce one. + +### Examples and Constant-Value Verification + +Examples are a first-class output of the generator, not an afterthought. The current manually configured documents render correctly but produce thin output in Scalar and Swagger UI: developers see schema shapes without representative values. Because the generator inspects each call site at compile time, it can promote constant arguments into per-endpoint **response-level** examples — the place OpenAPI 3.x renders concrete bodies most prominently. + +Schema-level vs response-level examples: + +- Canonical metadata schemas (e.g. the `InRange` metadata schema in the registry) are shared across endpoints. Per-call-site values cannot live on those schemas because they would leak across endpoints. Schema-level `Example` is reserved for envelope-level constants (e.g. `category: "Validation"`) attached centrally at `RegisterBuiltInValidationErrors()` time. +- Per-endpoint concrete values live on `OpenApiResponse.Content[mediaType].Examples`, the OpenAPI 3.x location intended for fully-formed response bodies. Each endpoint with constant-resolved arguments produces one or more named `OpenApiExample` entries containing a complete, valid problem-details body — the same shape the runtime JSON writers produce. + +Resolution rules: + +- For each metadata property bound by `sourceArgument`, the generator calls `SemanticModel.GetConstantValue(argument)`. When `HasValue` is true, the resolved value is captured and flows into the endpoint's example body. Literal arguments (`1`, `5`, `"^[a-z]+$"`), `const` locals/fields, and constant expressions all flow through naturally. +- For each metadata property bound by `constantValue`, the rule attribute itself supplies the value. +- When an argument does not resolve to a constant, no example is emitted for that endpoint's error and no diagnostic is raised. The schema is still complete; only the concrete example body is omitted. +- "All metadata or none" applies per error-code-per-endpoint: if any required metadata value for a code does not resolve, no example is attached for that code at that endpoint. Half-filled bodies confuse Scalar's rendering more than no examples at all. + +Output target: `Microsoft.AspNetCore.OpenApi` 9+ emits OpenAPI 3.1, which prefers the `examples` collection (plural). Generated examples attach through the response-example APIs added in the next subsection. Snapshot tests cover both the JSON document and the rendered Scalar output for the sample to lock the rendering down. -### `Must`, `Predicate`, and `Custom` +The example feature is intentionally orthogonal to error-code coverage: a validator is still considered fully documented if its codes are all known, even when not all of its arguments resolved to constants. -Delegate-based validation is not generally analyzable and must be treated as a boundary: +### OpenAPI Bridge API Extensions for Examples + +The existing `Light.PortableResults.Validation.OpenApi` API surface lets callers manipulate metadata schemas but has no way to attach concrete response bodies. Source-generated examples cannot land without extending the bridge first. These extensions are part of this plan, not a separate prerequisite — the generator and the example-emitting API have value only together. + +Extensions required: + +1. **Builder shortcuts on `PortableValidationProblemOpenApiBuilder` for built-in typed codes.** Existing helpers like `WithInRangeError()` gain optional example parameters: `WithInRangeError(lowerBoundary: 1, upperBoundary: 5)`. When called with values, the helper records both the schema narrowing (current behavior) and an `OpenApiExample` for that error code at that endpoint. The same pattern applies to the other typed comparison/range helpers (`WithGreaterThanError`, `WithLessThanOrEqualToError`, etc.). +2. **A general-purpose response-example API** for codes without typed helpers and for user-defined codes: `WithErrorExample(string code, string? target, IReadOnlyDictionary? metadata)`. The metadata dictionary keys match the error's metadata schema; values are constants resolved by the generator (or supplied manually). The bridge composes one or more of these into a complete error body. +3. **Schema-level envelope examples**, attached centrally by `RegisterBuiltInValidationErrors()`. The validation OpenAPI bridge gains the ability to set `OpenApiSchema.Example` on the canonical envelope's `category` property to `"Validation"`. The registration call accepts an optional override so users who change the default category get a matching example. +4. **Document transformer support.** The transformer reads endpoint metadata for examples, builds `OpenApiExample` instances by emitting a `JsonNode` tree shaped like the runtime JSON output (no reflection, no `JsonSerializer` calls — pure node construction), and attaches them to `OpenApiResponse.Content[mediaType].Examples`. Example names are deterministic (`"InRange-1-5"`, `"NotEmpty"`, etc.) so snapshot tests are stable. +5. **Format alignment.** Examples must be valid against the response's selected `ValidationProblemSerializationFormat` (Compact, Rich, AspNetCoreCompatible). The transformer picks the body shape that matches the format the endpoint serves, so what Scalar shows is structurally equivalent to what the endpoint actually returns at runtime. + +These APIs are public and usable without the source generator. Manual callers can supply examples too; the source generator just becomes the most ergonomic path because it derives them from existing validator code. + +### `Must`, `Predicate`, `Custom`, and `ErrorOverrides` + +Delegate-based and override-based validation is not generally analyzable and must be treated as a boundary: - `Must(predicate)` with no explicit definition remains opaque because the built-in `Predicate` code intentionally has no stable global metadata contract. - `Must(predicate, definition)` is documentable only when the supplied definition is statically resolvable and its type has a validation error contract attribute. -- `Must(predicate, overrides)` is documentable only when the override code and metadata shape are explicitly declared through a documentation hint; arbitrary `MetadataObject` construction should not be interpreted. - `Custom(...)` is opaque by default because the delegate can add zero, one, or many errors to the context. +- `ErrorOverrides` (any check chain that supplies an `ErrorOverrides` argument to override the default error code or metadata) is **out of scope for this plan**. Arbitrary `MetadataObject` construction is not interpreted at build time, and the override values often come from runtime expressions rather than constants. A future plan may add documentation hints that pair with `ErrorOverrides`, but for now any check using `ErrorOverrides` produces an opaque-flow diagnostic and requires the user to fall back to an explicit emitted-error hint or `AllowUnknownErrorCodes()`. Provide explicit opt-ins for opaque flows: @@ -202,6 +272,19 @@ Provide explicit opt-ins for opaque flows: Diagnostics should be helpful but not hostile. Opaque flows in a marked validator should produce warnings by default, and generated code should either omit those flows or call `AllowUnknownErrorCodes()` only when the user explicitly opted into that behavior. The generator should not silently weaken an exhaustive schema. +### Diagnostics + +The generator emits diagnostics only when a user can act on them. Successful recognition of a rule is silent — the proof is in the generated source, and routine "rule recognized" output would only add noise to IDE error lists. + +Severity assignments: + +- **Error** — the marked validator cannot be processed at all. The generator skips emitting a partial declaration for it. Triggers: validator class is not `partial`; an enclosing type is not `partial`; validator is an open generic; `[GeneratePortableValidationOpenApi]` is applied to an `AsyncValidator<...>`; user-defined check or definition has a malformed attribute combination (e.g. `[ValidationRuleMetadata(sourceArgument: "...")]` referencing a parameter that does not exist). +- **Warning** — the validator can be processed, but the generated schema is weaker than the user probably expects. Triggers: opaque `Must` / `Custom` / `ErrorOverrides` flow without an explicit hint; a metadata schema is dropped because a user-defined property uses an unsupported complex type and falls back to an unconstrained schema. +- **Info** — the generator skipped something the user might want to know about, but the skip is design-correct. Triggers: a check chain nested inside control flow was not analyzed; a marked validator yielded zero error codes (likely a misconfiguration but not necessarily wrong). +- **Hidden / no diagnostic** — successful recognition; constant-argument resolution succeeded; no opaque flows present; all enclosing types were partial. Silence is the success signal. + +Diagnostic ids use the `LPRSG` prefix (Light.PortableResults Source Generator) and live in a single contiguous range (`LPRSG0001`–`LPRSG00xx`). Ids are stable so users can configure them in `.editorconfig`. Every diagnostic carries a help link in the generator's documentation describing the supported workarounds. + ### NativeAOT and Performance Generated code must be startup-only metadata code and must not affect the runtime validation hot path. The generator itself runs at build time; the emitted code runs only when endpoints are mapped and OpenAPI metadata is attached. @@ -214,33 +297,49 @@ NativeAOT requirements: - no generated dependency on serializer metadata for generated validation metadata schemas - generated schema factories create fresh `OpenApiSchema` instances to avoid mutable schema reuse -The source generator should be incremental and symbol-based. It should avoid whole-compilation scans where possible by filtering on the opt-in validator attribute and validation-rule attributes. +Source-generator performance is governed by IDE responsiveness, not by build-time wall clock. A source-generator run executes on every keystroke that changes a relevant compilation; the worst regression mode is not slow analysis but **broken caching**, where pipeline values fail value-equality and Roslyn re-runs every step. To stay fast: + +- Use `IIncrementalGenerator`. Do not implement the legacy `ISourceGenerator`. +- Discover marked validators via `context.SyntaxProvider.ForAttributeWithMetadataName(...)` so unrelated source changes do not invalidate the pipeline. +- Make every value flowing through the pipeline value-equal: records, sealed classes with overridden `Equals`/`GetHashCode`, or `EquatableArray` (never raw `ImmutableArray`, which uses reference equality). +- **Never let `ISymbol` instances flow past the first pipeline stage.** Symbols hold references to `Compilation`, which is a fresh instance on every keystroke, so any DTO containing an `ISymbol` will compare unequal across runs and silently break the cache. The first node materializes everything it needs (names, metadata key strings, attribute argument values, locations) into a primitives-only DTO; downstream stages never see symbols. This is the single most common source-generator perf bug in the wild. +- Structure the analysis pass as a pure function from `(Compilation, INamedTypeSymbol)` to a plain DTO. The pipeline is then `[validator symbol → primitives DTO] → [emitted source]`, with caching at every stage. +- Avoid touching `Compilation.GetSemanticModel(...)` for unrelated trees. Walk only the marked validator's syntax. + +For a typical validator (tens of lines, a flat sequence of check statements), expected per-validator analysis time is single-digit milliseconds against a warm semantic model. With caching working correctly, untouched validators contribute zero work between keystrokes. + +A pre-build CLI / MSBuild task is intentionally **not** chosen. CLI tools are appropriate when inputs are external (a YAML, a `.proto`, a remote schema) or when generated output is large and version-pinned. Here the inputs are C# symbols in the same compilation, and the value of the feature is in-IDE feedback: red squigglies on `Must(...)` without a hint, instant compile errors when a generated `WithInRangeError()` call references a renamed helper, diagnostics surfaced in the IDE's error list. A pre-build CLI would forfeit all of that for no real performance benefit. ### Tests Tests should cover both generator behavior and integrated OpenAPI output: - generated code for a validator similar to `NewMovieRatingValidator` -- diagnostics for non-partial marked validators +- diagnostics for non-partial marked validators (and non-partial enclosing types) - diagnostics for unsupported `Must` / `Custom` patterns -- built-in typed helper generation for `IsInBetween(1, 5)` and similar comparison/range checks -- fixed metadata built-ins such as `HasLengthIn(10, 1000)` +- diagnostics for check chains nested inside control flow (`if`, `switch`, `foreach`, etc.) — the chain is skipped, the diagnostic is emitted +- built-in typed helper generation for `IsInRange(1, 5)` and similar comparison/range checks +- fixed metadata built-ins such as `HasLengthInRange(10, 1000)` - user-defined check methods with annotated error definitions and metadata mappings - explicit unknown-error opt-in producing `AllowUnknownErrorCodes()` - generated Minimal API endpoint metadata producing the same `oneOf` / metadata schemas as manual builder calls +- response-level examples appear when call-site arguments are constant; the example body matches the runtime JSON shape for the active `ValidationProblemSerializationFormat`; no example is emitted when arguments are not constant +- example `target` strings are derived from `context.Check(dto.Property)` member-access arguments, respect `[JsonPropertyName]` overrides and the configured `JsonNamingPolicy`, and fall back to `null` for complex targets +- envelope-level `category: "Validation"` example appears on the canonical envelope schema and reflects user overrides +- the analysis pass returns equal DTOs across runs for unchanged input, so the incremental pipeline caches correctly (in particular, the DTO contains no `ISymbol` references) - a NativeAOT-oriented generated source assertion that no reflection/schema-exporter APIs appear in generated code -Snapshot tests are appropriate for generated source, but the tests should also compile generated output and assert the resulting OpenAPI document so the generator cannot drift from the transformer contract. +Snapshot tests are appropriate for generated source (`Verify.SourceGenerators` is the recommended harness). The tests must also compile generated output and assert the resulting OpenAPI document so the generator cannot drift from the transformer contract. A baseline test for the sample compares the source-generated document against the previously manually configured one and asserts non-regression on schema/code/`oneOf` content; the response-level examples added by source generation are an expected addition over the baseline, not a difference the test should reject. ### Documentation and Sample -Update the README and the NativeAOT sample to show the intended user-facing workflow: +Update the README and the `NativeAotMovieRating` sample to show the intended user-facing workflow: ```csharp [GeneratePortableValidationOpenApi] public sealed partial class NewMovieRatingValidator : Validator { - // existing PerformValidation implementation + // existing PerformValidation implementation, now using IsInRange(1, 5) } app.MapPut("/api/moviesRatings", AddMovieRating) @@ -248,8 +347,26 @@ app.MapPut("/api/moviesRatings", AddMovieRating) configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich)); ``` -The docs should clearly explain the supported subset and the escape hatches. The most important message is that source generation is precise when validation rules are explicit and annotated, while arbitrary delegates and imperative custom validation require explicit documentation hints. +Concrete sample changes required: + +- `NewMovieRatingValidator` becomes `partial`, gains `[GeneratePortableValidationOpenApi]`, and updates the rating check from `IsInBetween(1, 5)` to `IsInRange(1, 5)`. +- `NewMovieRatingEndpoint` removes its manual `WithErrorCodes(...)` and `WithInRangeError()` calls and replaces them with `ProducesPortableValidationProblemFor(...)`. +- The Scalar UI (`/docs`) and Swagger UI (`/swagger/index.html`) endpoints both render the source-generated document with concrete response-level example bodies that include `lowerBoundary: 1`, `upperBoundary: 5`, the length-range bounds, and the envelope's `category: "Validation"`. A short README section calls out the visible difference and notes any remaining rendering divergence between the two UIs (Scalar and Swashbuckle's UI handle OpenAPI 3.x examples slightly differently; pin which behavior is canonical). +- A document-equivalence test in the test suite (not in the sample) compares the generated document against the pre-source-gen baseline so the swap is provably non-breaking. + +The docs should clearly explain the supported subset and the escape hatches. The most important messages are: source generation is precise when validation rules are explicit and annotated; arbitrary delegates and imperative custom validation require explicit documentation hints; and the analysis only sees top-level statements in the validation method body. ### Scope Boundaries -This plan does not attempt to infer arbitrary C# semantics or execute validators at build time. It does not generate examples yet, although the manifest model should leave room for examples later. It does not replace the manual `ProducesPortableValidationProblem(...)` builder APIs; generated contracts compose with them. It does not require every validator in an application to opt in. It does not change runtime validation behavior or the wire format. +This plan does not attempt to infer arbitrary C# semantics or execute validators at build time. It does not analyze checks nested inside control-flow constructs; those are skipped with a diagnostic. It does not replace the manual `ProducesPortableValidationProblem(...)` builder APIs; generated contracts compose with them. It does not require every validator in an application to opt in. It does not change runtime validation behavior or the wire format. + +Out of scope for this iteration, intentionally deferred to keep the first generator landing small: + +- **`AsyncValidator` and `PerformValidationAsync`** — async validators produce a diagnostic when marked. +- **MVC integration** — Minimal APIs only; the generated static contract is reusable from MVC in a follow-up plan. +- **`ErrorOverrides`** — chains that override the default error code or metadata are treated as opaque. +- **Complex target-string inference** — chained member access, indexers, and computed targets are out of scope. The simple `context.Check(dto.Property)` case is supported in v1. +- **Child-validator composition** — validators invoking nested validators. +- **Collection-item validation.** + +Each of these can be added without breaking the contracts established in this plan. The current plan deliberately keeps the analysis surface small so the source-generation infrastructure lands cleanly before scope expands. From 643e20ae27aa104de96b19b1eb98cac246f1d80c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 2 May 2026 11:58:13 +0200 Subject: [PATCH 03/17] chore: more refinement for OpenAPI Source Generator plan Signed-off-by: Kenny Pflug --- ai-plans/0042-openapi-source-generation.md | 97 ++++++++++++++++------ 1 file changed, 71 insertions(+), 26 deletions(-) diff --git a/ai-plans/0042-openapi-source-generation.md b/ai-plans/0042-openapi-source-generation.md index b19e9d7..72318b6 100644 --- a/ai-plans/0042-openapi-source-generation.md +++ b/ai-plans/0042-openapi-source-generation.md @@ -8,36 +8,42 @@ The scope is intentionally narrow: - **Minimal APIs only.** MVC integration is a follow-up. - **Synchronous validators only.** `Validator` and `Validator` are supported; `AsyncValidator` and `PerformValidationAsync` are deferred. -- **Top-level statements only.** Within `PerformValidation`, anything nested inside control-flow constructs (`if`, `switch`, `foreach`, etc.) is skipped with a diagnostic. +- **Top-level statements only.** Within `PerformValidation`, anything nested inside control-flow constructs (`if`, `switch`, `foreach`, etc.) is skipped with a warning diagnostic. - **Annotated rules only.** Built-in and explicitly annotated check methods are recognized; `Must`, `Predicate`, `Custom`, and `ErrorOverrides`-driven overrides are opaque unless the user supplies explicit hints. -Examples are promoted to a first-class output. The generator inspects each call site, verifies that metadata-bound arguments are compile-time constants, and emits those values as response-level examples on the affected endpoints (`OpenApiResponse.Content[mt].Examples`). Schema-level examples remain reserved for envelope-wide constants like `category: "Validation"`. This materially improves the rendered Scalar and Swagger UI output without any extra annotation effort beyond the rule attributes themselves. +Examples are promoted to a first-class output. The generator inspects each call site, verifies that metadata-bound arguments are compile-time constants, and emits a single validator-level response example on the affected endpoints (`OpenApiResponse.Content[mt].Examples`). Schema-level examples remain reserved for envelope-wide constants like `category: "Validation"`. This materially improves the rendered Scalar and Swagger UI output without any extra annotation effort beyond the rule attributes themselves. Two pre-1.0 alignment items in `Light.PortableResults.Validation` are folded into this plan: the comparative-range methods rename from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` so method names match `ValidationErrorCodes.InRange` / `NotInRange`; and the recognized signatures stay on paired primitive arguments rather than a `Range` struct, because paired arguments resolve cleanly through `SemanticModel.GetConstantValue(...)` while a struct would force builder-chain pattern matching or cross-file symbol following. ## Acceptance Criteria - [ ] A new source-generator project is added for validation OpenAPI generation. It targets **`netstandard2.0`** because Roslyn analyzers and source generators are loaded into the C# compiler host and must comply with that contract; this is a hard requirement, not a style choice. The project is packaged as a Roslyn analyzer, uses `LangVersion=latest` (with `PolySharp` or hand-written shims for newer language features as needed), and does not add runtime dependencies to applications beyond the existing validation/OpenAPI packages. +- [ ] The generator project does not reference the validation or OpenAPI runtime assemblies as normal assembly references. It resolves public APIs by metadata name and uses linked/shared source only for small constant-only files where sharing is materially safer than duplicating strings. Runtime implementation files are not linked into the generator. - [ ] The generator is implemented as an `IIncrementalGenerator` and uses `ForAttributeWithMetadataName(...)` to discover marked validators, so unrelated source changes do not invalidate the generation pipeline. Every value flowing through the pipeline implements value-equality (records or `EquatableArray`) so cached steps remain shareable. -- [ ] The analysis pass is structured as a pure function from `(Compilation, INamedTypeSymbol)` to a plain DTO describing the generated builder calls. The `IIncrementalGenerator` glue is a thin adapter on top, so the analysis is testable directly without driving the full generator harness and reusable for a future MVC analyzer. +- [ ] The analysis pass is structured as a pure function from `(Compilation, INamedTypeSymbol, CancellationToken)` to a plain DTO describing the generated builder calls. The `IIncrementalGenerator` glue is a thin adapter on top, so the analysis is testable directly without driving the full generator harness and reusable for a future MVC analyzer. The analysis honors cancellation throughout syntax and semantic-model work. - [ ] Public generator-facing attributes are added without violating layering: validation-rule and validation-error-contract attributes live in the validation layer and do not reference `Microsoft.OpenApi`; OpenAPI-specific opt-in attributes live in `Light.PortableResults.Validation.OpenApi`. - [ ] Built-in validation checks and built-in validation error definitions are annotated so the generator can identify their error code, metadata shape, and method-argument-to-metadata bindings without hard-coded method-name tables. - [ ] The comparative-range check methods are renamed from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` so method names match `ValidationErrorCodes.InRange` / `NotInRange`. All callers in the solution (sample, tests, internal usages) are updated. This is a breaking change accepted pre-1.0. - [ ] A public static-abstract contract interface is added in `Light.PortableResults.Validation.OpenApi`, allowing generated partial validator classes to expose `ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)` without reflection. - [ ] A Minimal API helper such as `ProducesPortableValidationProblemFor(...)` is added. It calls the generated validator contract and then applies any caller-supplied manual builder configuration. -- [ ] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial` and every enclosing type to be `partial`. The generator emits clear diagnostics when the class or any enclosing type is not partial, when the validator is an open generic, or when the validator is otherwise unsupported. +- [ ] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial`, non-nested, non-generic, and a direct subclass of `Validator` or `Validator`. Nested validators, open or closed generic validator types, generic enclosing types, indirect validator inheritance, custom validator base classes, and otherwise unsupported validators produce clear diagnostics. The diagnostic for custom/indirect bases explicitly tells users that v1 requires directly inheriting from `Validator` or `Validator`. This keeps the first implementation's emitted partial-type shape intentionally simple. - [ ] The generator supports synchronous `Validator` and `Validator` by inspecting the `PerformValidation` method body. `AsyncValidator` / `AsyncValidator` are explicitly out of scope for this plan; marking an async validator with the OpenAPI generation attribute produces a diagnostic that points at the synchronous-validator restriction. - [ ] The generator recognizes direct `ValidationContext.Check(...)` calls and fluent chains of supported check extension methods. It supports both standalone calls and assignments that consume a `ValidatedValue` returned by a check chain. -- [ ] The generator analyzes only **top-level statements** in the validation method body. Check expressions nested inside `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambdas, or local functions are skipped for schema generation and produce an info-level diagnostic that suggests lifting the check out of the control-flow construct or accepting the gap via `AllowUnknownErrorCodes()`. Local variable declarations and the trailing `checkpoint.ToValidatedValue(...)` are silently allowed. +- [ ] The generator analyzes only **top-level statements** in the validation method body. Check expressions nested inside `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambdas, or local functions are skipped for schema generation and produce a warning diagnostic that suggests lifting the check out of the control-flow construct, adding explicit emitted-error hints, or accepting the gap via `AllowUnknownErrorCodes()`. Local variable declarations and the trailing `checkpoint.ToValidatedValue(...)` are silently allowed. - [ ] The generator derives built-in no-metadata and fixed-metadata errors as `WithErrorCodes(...)` calls and derives built-in typed comparison/range errors as the existing typed helper calls such as `WithInRangeError()`. - [ ] The generator supports user-defined validation error definitions and user-defined check extension methods when they are explicitly annotated with generator-facing metadata that identifies the error code and metadata schema. -- [ ] Constant-valued arguments reach the generated OpenAPI documents as concrete response-level examples. The generator inspects each metadata-bound argument with `SemanticModel.GetConstantValue(...)`. When the value resolves to a constant, it flows into the endpoint's response example; when it does not, no example is emitted for that endpoint and no diagnostic is raised. The validation OpenAPI bridge supplies envelope-level constants (e.g. `category: "Validation"`) once at registration time. -- [ ] The `Light.PortableResults.Validation.OpenApi` package is extended with the public APIs needed to attach response-level examples: optional example parameters on the existing typed helpers (`WithInRangeError`, `WithGreaterThanError`, etc.), a general-purpose `WithErrorExample(...)` helper for codes without typed shortcuts, and document-transformer support that composes endpoint-declared examples into `OpenApiResponse.Content[mediaType].Examples` as a `JsonNode` tree matching the active `ValidationProblemSerializationFormat`. `RegisterBuiltInValidationErrors()` attaches the envelope-level `category: "Validation"` schema example with an override hook for users who change the default category. +- [ ] Generator-facing attributes use source-generator-friendly constructor shapes only: primitive values, strings, `Type`, enums, and arrays of those values. Attribute models do not require interpreting arbitrary object graphs or runtime-created values. +- [ ] Constant-valued arguments reach the generated OpenAPI documents as one concrete validator-level response example per generated endpoint response. The generator inspects each metadata-bound argument with `SemanticModel.GetConstantValue(...)`. When a call site's required metadata values resolve to constants, that call site contributes an error entry to the endpoint's example body; when they do not, that call site is omitted from the example and no diagnostic is raised. The schema/code documentation remains complete. The validation OpenAPI bridge supplies envelope-level constants (e.g. `category: "Validation"`) once at registration time. +- [ ] The `Light.PortableResults.Validation.OpenApi` package is extended with the public APIs needed to attach response-level examples: optional example-entry parameters on the existing typed helpers (`WithInRangeError`, `WithGreaterThanError`, etc.), a general-purpose `WithErrorExample(...)` helper for codes without typed shortcuts, and document-transformer support that composes endpoint-declared example entries into one `OpenApiResponse.Content[mediaType].Examples` body as a `JsonNode` tree matching the active `ValidationProblemSerializationFormat`. `RegisterBuiltInValidationErrors()` attaches the envelope-level `category: "Validation"` schema example with an override hook for users who change the default category. - [ ] `Must(...)`, `Predicate`, and delegate-based validation are treated as opaque unless the failing definition or wrapper method is statically documentable. Unsupported opaque flows produce diagnostics and require an explicit opt-in to unknown errors or explicit emitted-error hints. - [ ] `Custom(...)` is treated as opaque by default because it can emit zero, one, or many errors. A documented validator that uses `Custom(...)` must either provide explicit emitted-error hints or opt into unknown errors. - [ ] Generated code is NativeAOT-safe: it does not use reflection, does not instantiate arbitrary validation error definitions for documentation, and does not call ASP.NET Core's runtime schema exporter for generated validation metadata. +- [ ] Generated source uses a controlled, deterministic using block and does not depend on the consumer project's implicit usings, global usings, aliases, or local using directives. +- [ ] Generated source hint names are deterministic and stable across machines and builds, based on the validator's fully qualified metadata name. This keeps IDE behavior and snapshot tests stable. +- [ ] Automatic source-null errors emitted before `PerformValidation` runs are explicitly out of scope. The plan documents that users should add an explicit top-level `IsNotNull()` rule or manual OpenAPI builder configuration when they want source-null errors documented in v1. - [ ] The `NativeAotMovieRating` sample is updated end to end: `NewMovieRatingValidator` becomes `partial` and is annotated with `[GeneratePortableValidationOpenApi]`; `NewMovieRatingEndpoint` replaces its manual `WithErrorCodes(...)` / `WithInRangeError()` configuration with `ProducesPortableValidationProblemFor(...)`; an automated test asserts that the resulting OpenAPI document preserves everything the manually configured document had — schemas, error codes, `oneOf` structure, and exhaustiveness flags — after canonical key ordering, while the new document is *additionally* allowed to carry response-level examples that the baseline lacks (the examples are the point of the swap, not a regression); and the rendered Scalar (`/docs`) and Swagger UI (`/swagger/index.html`) outputs both show concrete example values for at least the `InRange` and `LengthInRange` error metadata. - [ ] Automated tests are added for generator output, diagnostics, Minimal API integration, built-in rule coverage, user-defined rule support, control-flow-skipped checks, constant-vs-non-constant example handling, opaque `Must` / `Custom` handling, and NativeAOT-safe generated code shape. Snapshot tests cover emitted source; document-generation tests cover the resulting OpenAPI document so the generator cannot drift from the transformer contract. +- [ ] Packaging is validated with a local NuGet-package consumer test: `Light.PortableResults.Validation.OpenApi` is packed, consumed from a local feed by a small project without project references to the source tree, and verified to load and run the bundled analyzer/source generator. If this is too slow for the normal unit-test loop, it may live behind a dedicated integration/package test target. - [ ] Documentation is updated to explain the generator opt-in model, supported validation patterns, the top-level-statements-only analysis boundary, required annotations for custom rules, the constant-argument requirement for examples, limitations around delegates and imperative custom validation, and how to mix generated and manual endpoint OpenAPI configuration. ## Technical Details @@ -48,12 +54,23 @@ Add a new source-generator project, `Light.PortableResults.Validation.OpenApi.So **Packaging:** the generator does not ship as its own NuGet. It is bundled into the existing `Light.PortableResults.Validation.OpenApi` package as an analyzer asset: the runtime DLL goes to `lib/`, the generator DLL goes to `analyzers/dotnet/cs/` in the same `.nupkg`. This is the standard pattern for "library + companion generator" (Refit, Mapperly, MediatR.SourceGenerator) and avoids the "I installed the bridge but the generator doesn't run" footgun. The bundling is wired through a `` from `Light.PortableResults.Validation.OpenApi.csproj` to the generator project, plus `` entries for packing. -The generator project may reference the validation and validation-OpenAPI assemblies at design time for symbol names and attribute definitions, but it must not redistribute those assemblies — the consumer's own references provide them at compile time. +The generator project must not take normal assembly references on the validation or validation-OpenAPI runtime assemblies. Analyzer load contexts are intentionally isolated, and depending on runtime DLLs from the analyzer can produce package-only failures even when project-reference builds appear healthy. The generator should resolve public APIs by fully qualified metadata name (`Compilation.GetTypeByMetadataName(...)`) and keep a small internal catalog of known metadata names. + +For shared stable strings such as built-in error codes and metadata keys, prefer one of two approaches: + +- Link narrow constant-only source files into the generator project when sharing prevents drift and those files have no runtime dependencies. +- Duplicate the strings in a generator-local `KnownValidationNames` / `KnownValidationContracts` style type when linking would pull in implementation concerns. + +Do not link runtime implementation-heavy files from `Light.PortableResults.Validation` or `Light.PortableResults.Validation.OpenApi` into the generator. The generator's design-time knowledge should be names and constants, not runtime behavior. Add a matching test project under `tests/`, for example `Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests`. The tests should compile in-memory source snippets with the generator, assert diagnostics, inspect generated source (snapshot tests using `Verify.SourceGenerators` are appropriate), and run at least a few generated validators through the existing in-memory OpenAPI document test utilities. Tests should also cover the analysis pass directly by calling the pure analysis function with a parsed `Compilation` and asserting the DTO it returns, independent of the generator driver. +Add a package-consumer validation test or script that packs `Light.PortableResults.Validation.OpenApi`, installs it from a local feed into a small temporary consumer project, and builds that project without project references to the source tree. This verifies that the generator is actually included under `analyzers/dotnet/cs/`, that it loads without missing runtime assembly dependencies, and that generated endpoint metadata compiles in the package-consumer scenario. + The solution currently has no Roslyn source-generation infrastructure, so this plan should establish the package layout, test helpers, and central package versions for `Microsoft.CodeAnalysis.CSharp`, `Microsoft.CodeAnalysis.Analyzers`, `Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing`, and `Verify.SourceGenerators` in a way that can be reused by future generators. Pin a Roslyn version that is widely available across SDKs (avoid pinning to the bleeding edge — generators must run inside the user's chosen SDK). +Analyzer-only package references in the generator project should use `PrivateAssets="all"` and the appropriate `IncludeAssets` metadata so Roslyn/test dependencies do not leak transitively to consumers of `Light.PortableResults.Validation.OpenApi`. The bundled package should expose the runtime OpenAPI bridge normally, but the source generator and its analyzer dependencies remain analyzer implementation details. + ### Attribute Model Keep the attribute model split by concern: @@ -90,6 +107,8 @@ public static Check IsInRange( `ValidationRuleMetadataAttribute` supports two forms for resolving a value at generation time: `sourceArgument` for values pulled from a call-site argument by parameter name, and `constantValue` for fixed values that do not depend on the call site. Most built-in rules use `sourceArgument` exclusively; `constantValue` exists for user-defined rules whose definition pins a metadata property to a fixed value. The generator resolves `sourceArgument` values via `SemanticModel.GetConstantValue(...)` on the matching `ArgumentSyntax`; when the value is constant, it flows into the response example. +All generator-facing attributes should stay within compiler-friendly attribute argument types: primitive values, strings, `Type`, enums, and arrays of those values. Do not introduce attribute properties that require the generator to interpret arbitrary objects, runtime-created metadata, delegates, or serialized blobs. + Note that `Error.Category` is a top-level property on the error envelope, not a metadata key. All validation rules produce `ErrorCategory.Validation` by default; users can still override the category at registration or call site. The example value `"Validation"` for the envelope's `category` schema is attached centrally by the validation OpenAPI bridge in `RegisterBuiltInValidationErrors()` and does not appear on per-rule annotations. A user who overrides the category gets that override reflected in the generated example. For user-defined definitions: @@ -142,7 +161,15 @@ public sealed partial class NewMovieRatingValidator : IPortableValidationOpenApi } ``` -The generated method should be deterministic, stable across builds, and idempotent with existing builder semantics. It should group simple built-in codes into as few `WithErrorCodes(...)` calls as practical and emit typed helper calls for typed comparison/range rules. The same error code emitted from multiple call sites in the validator is deduplicated: the example above shows `IsNotEmpty` called twice (on `dto.Id` and `dto.MovieId`) producing a single `NotEmpty` entry. Deduplication is by code string, not by call site or target. +The first implementation only supports validators that are top-level, non-generic, non-nested, and directly inherit from `Validator` or `Validator`. This deliberately avoids the complexity of reproducing containing type hierarchies, generic parent declarations, generic constraints, and indirect inheritance chains in generated partial source. Unsupported shapes produce diagnostics and no partial declaration is emitted. + +Generated source should use a controlled, deterministic using block for readability. The emitted file must not depend on the consumer project's implicit usings, global usings, aliases, or local using directives; every type used by the generated source must either be covered by the generator's own using block or fully qualified when that is clearer. + +Generated source hint names should be stable and deterministic, using the validator's fully qualified metadata name sanitized for file-name safety, for example `NativeAotMovieRating.NewMovieRating.NewMovieRatingValidator.PortableValidationOpenApi.g.cs`. + +The generated method should be deterministic, stable across builds, and idempotent with existing builder semantics. It should group simple built-in codes into as few `WithErrorCodes(...)` calls as practical and emit typed helper calls for typed comparison/range rules. For schema configuration, the same error code emitted from multiple call sites in the validator is deduplicated: the example above shows `IsNotEmpty` called twice (on `dto.Id` and `dto.MovieId`) producing a single `NotEmpty` schema entry. Deduplication is by code string, not by call site or target. + +Example generation keeps call-site information separate from schema deduplication. A validator-level response example can include both `dto.Id` and `dto.MovieId` as separate error entries even though both use the same `NotEmpty` schema contract. ### Endpoint Integration @@ -181,7 +208,7 @@ Within the analyzed method, the generator recognizes a deliberately narrow set o - the trailing `return checkpoint.ToValidatedValue(...);` - local variable declarations and trivial expression statements that contain no check chain (these are silently allowed) -Anything nested inside an `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambda body, or local function body is **not analyzed for schema generation**. The generator emits a single info-level diagnostic per skipped chain that points at the call site and explains the supported workarounds: lift the check out of the control-flow construct so it runs unconditionally, or accept the missing code on the documented schema by opting the endpoint into `AllowUnknownErrorCodes()`. This is not "best-effort" control-flow analysis — it is a hard, easily-explained boundary that matches the shape of every validator in the existing sample. +Anything nested inside an `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambda body, or local function body is **not analyzed for schema generation**. The generator emits a single warning diagnostic per skipped chain that points at the call site and explains the supported workarounds: lift the check out of the control-flow construct so it runs unconditionally, provide explicit emitted-error hints, or accept the missing code on the documented schema by opting into `AllowUnknownErrorCodes()`. This is not "best-effort" control-flow analysis — it is a hard, easily-explained boundary that matches the shape of every validator in the existing sample. Within an analyzed chain, the generator walks each invocation, looks up `[ValidationRule]` and `[ValidationRuleMetadata]` attributes on the called method, and records: @@ -195,6 +222,8 @@ User-defined helper methods are recognized only when annotated themselves with ` Edge case: a bare `context.Check(dto.Property);` call with no subsequent assertion contributes no error codes. The generator silently ignores such calls — they are not malformed, just incomplete validation. No diagnostic is emitted. +Automatic source-null errors emitted by `Validator` / `Validator` before `PerformValidation` runs are out of scope for this iteration. The generator analyzes the validator body, not the runtime validator pipeline. If users want a source-null error documented in v1, they should add an explicit top-level `IsNotNull()` rule in `PerformValidation` or add the code manually via the endpoint builder. + ### Built-In Rule Coverage Annotate the built-in check extension methods rather than hard-coding method names in the generator. This keeps the generator extensible and gives user-defined check methods the same path as built-ins. @@ -221,21 +250,28 @@ User-defined rules should follow the same model as built-ins: Generated inline schema factories should be authored with `OpenApiSchema` construction and `PortableOpenApiSchemaTypeMapper`, not with reflection or `JsonSchemaExporter`. If a user-defined metadata property uses an unsupported complex type, the generator falls back to an unconstrained `OpenApiSchema` for that property and emits a warning diagnostic. A future iteration may add an explicit schema-hint attribute for opt-in narrowing of those properties; this plan does not introduce one. +Custom rule annotations need conflict checks: + +- Malformed annotations that affect the marked validator are errors, for example metadata bound to a parameter name that does not exist, duplicate metadata keys with incompatible sources, or an error definition contract that omits a required code. +- Two documentable rules used by the same marked validator cannot claim the same error code with incompatible metadata contracts. This produces a diagnostic because the generated endpoint schema would otherwise be ambiguous. +- Compatible duplicate declarations for the same code and metadata shape are allowed and deduplicated at schema-generation time. + ### Examples and Constant-Value Verification -Examples are a first-class output of the generator, not an afterthought. The current manually configured documents render correctly but produce thin output in Scalar and Swagger UI: developers see schema shapes without representative values. Because the generator inspects each call site at compile time, it can promote constant arguments into per-endpoint **response-level** examples — the place OpenAPI 3.x renders concrete bodies most prominently. +Examples are a first-class output of the generator, not an afterthought. The current manually configured documents render correctly but produce thin output in Scalar and Swagger UI: developers see schema shapes without representative values. Because the generator inspects each call site at compile time, it can promote constant arguments into a per-validator **response-level** example — the place OpenAPI 3.x renders concrete bodies most prominently. Schema-level vs response-level examples: - Canonical metadata schemas (e.g. the `InRange` metadata schema in the registry) are shared across endpoints. Per-call-site values cannot live on those schemas because they would leak across endpoints. Schema-level `Example` is reserved for envelope-level constants (e.g. `category: "Validation"`) attached centrally at `RegisterBuiltInValidationErrors()` time. -- Per-endpoint concrete values live on `OpenApiResponse.Content[mediaType].Examples`, the OpenAPI 3.x location intended for fully-formed response bodies. Each endpoint with constant-resolved arguments produces one or more named `OpenApiExample` entries containing a complete, valid problem-details body — the same shape the runtime JSON writers produce. +- Per-endpoint concrete values live on `OpenApiResponse.Content[mediaType].Examples`, the OpenAPI 3.x location intended for fully-formed response bodies. Each generated validator/endpoint response produces at most one named `OpenApiExample` entry containing a complete, valid problem-details body with all documentable call-site errors — the same shape the runtime JSON writers produce. Resolution rules: - For each metadata property bound by `sourceArgument`, the generator calls `SemanticModel.GetConstantValue(argument)`. When `HasValue` is true, the resolved value is captured and flows into the endpoint's example body. Literal arguments (`1`, `5`, `"^[a-z]+$"`), `const` locals/fields, and constant expressions all flow through naturally. - For each metadata property bound by `constantValue`, the rule attribute itself supplies the value. -- When an argument does not resolve to a constant, no example is emitted for that endpoint's error and no diagnostic is raised. The schema is still complete; only the concrete example body is omitted. -- "All metadata or none" applies per error-code-per-endpoint: if any required metadata value for a code does not resolve, no example is attached for that code at that endpoint. Half-filled bodies confuse Scalar's rendering more than no examples at all. +- When a metadata-bearing call site's required arguments do not resolve to constants, that call site is omitted from the response example and no diagnostic is raised. The schema is still complete; only the concrete example entry for that call site is omitted. +- "All metadata or none" applies per call site: if any required metadata value for a call site does not resolve, that call site does not contribute an error entry to the validator-level example. Half-filled bodies confuse Scalar's rendering more than no example entry for that rule. +- No-metadata errors can contribute example entries as long as the generator can build a useful target or intentionally emit `null` for the target. If no call site contributes a useful example entry, no response example is emitted. Output target: `Microsoft.AspNetCore.OpenApi` 9+ emits OpenAPI 3.1, which prefers the `examples` collection (plural). Generated examples attach through the response-example APIs added in the next subsection. Snapshot tests cover both the JSON document and the rendered Scalar output for the sample to lock the rendering down. @@ -247,12 +283,16 @@ The existing `Light.PortableResults.Validation.OpenApi` API surface lets callers Extensions required: -1. **Builder shortcuts on `PortableValidationProblemOpenApiBuilder` for built-in typed codes.** Existing helpers like `WithInRangeError()` gain optional example parameters: `WithInRangeError(lowerBoundary: 1, upperBoundary: 5)`. When called with values, the helper records both the schema narrowing (current behavior) and an `OpenApiExample` for that error code at that endpoint. The same pattern applies to the other typed comparison/range helpers (`WithGreaterThanError`, `WithLessThanOrEqualToError`, etc.). -2. **A general-purpose response-example API** for codes without typed helpers and for user-defined codes: `WithErrorExample(string code, string? target, IReadOnlyDictionary? metadata)`. The metadata dictionary keys match the error's metadata schema; values are constants resolved by the generator (or supplied manually). The bridge composes one or more of these into a complete error body. +1. **Builder shortcuts on `PortableValidationProblemOpenApiBuilder` for built-in typed codes.** Existing helpers like `WithInRangeError()` gain optional example-entry parameters: `WithInRangeError(target: "rating", lowerBoundary: 1, upperBoundary: 5)`. When called with values, the helper records both the schema narrowing (current behavior) and an example error entry for the validator-level response example. The same pattern applies to the other typed comparison/range helpers (`WithGreaterThanError`, `WithLessThanOrEqualToError`, etc.). +2. **A general-purpose response-example-entry API** for codes without typed helpers and for user-defined codes: `WithErrorExample(string code, string? target, IReadOnlyDictionary? metadata)`. The metadata dictionary keys match the error's metadata schema; values are constants resolved by the generator (or supplied manually). The bridge composes all endpoint-declared example entries into one complete validator-level error body. 3. **Schema-level envelope examples**, attached centrally by `RegisterBuiltInValidationErrors()`. The validation OpenAPI bridge gains the ability to set `OpenApiSchema.Example` on the canonical envelope's `category` property to `"Validation"`. The registration call accepts an optional override so users who change the default category get a matching example. -4. **Document transformer support.** The transformer reads endpoint metadata for examples, builds `OpenApiExample` instances by emitting a `JsonNode` tree shaped like the runtime JSON output (no reflection, no `JsonSerializer` calls — pure node construction), and attaches them to `OpenApiResponse.Content[mediaType].Examples`. Example names are deterministic (`"InRange-1-5"`, `"NotEmpty"`, etc.) so snapshot tests are stable. +4. **Document transformer support.** The transformer reads endpoint metadata for example entries, builds one `OpenApiExample` instance by emitting a `JsonNode` tree shaped like the runtime JSON output (no reflection, no `JsonSerializer` calls — pure node construction), and attaches it to `OpenApiResponse.Content[mediaType].Examples`. Example names are deterministic and response-scoped, for example `"ValidationProblem"` or a stable validator-derived name, so snapshot tests are stable. 5. **Format alignment.** Examples must be valid against the response's selected `ValidationProblemSerializationFormat` (Compact, Rich, AspNetCoreCompatible). The transformer picks the body shape that matches the format the endpoint serves, so what Scalar shows is structurally equivalent to what the endpoint actually returns at runtime. +Example entries are stored as structured endpoint metadata, not as prebuilt response bodies. This matters because `ProducesPortableValidationProblemFor(...)` applies the generated configuration first and the caller's manual configuration afterward. If the caller changes the validation problem format with `UseFormat(...)`, the document transformer composes the final example body after that format is known. + +Schema caching remains separate from examples. Equivalent error metadata schemas, such as multiple `InRange` contracts, should reuse the same component-level schema where the existing OpenAPI infrastructure can do so safely. Example bodies are endpoint/validator-level artifacts and preserve the call-site targets and constant values that make the example useful. + These APIs are public and usable without the source generator. Manual callers can supply examples too; the source generator just becomes the most ergonomic path because it derives them from existing validator code. ### `Must`, `Predicate`, `Custom`, and `ErrorOverrides` @@ -278,10 +318,10 @@ The generator emits diagnostics only when a user can act on them. Successful rec Severity assignments: -- **Error** — the marked validator cannot be processed at all. The generator skips emitting a partial declaration for it. Triggers: validator class is not `partial`; an enclosing type is not `partial`; validator is an open generic; `[GeneratePortableValidationOpenApi]` is applied to an `AsyncValidator<...>`; user-defined check or definition has a malformed attribute combination (e.g. `[ValidationRuleMetadata(sourceArgument: "...")]` referencing a parameter that does not exist). -- **Warning** — the validator can be processed, but the generated schema is weaker than the user probably expects. Triggers: opaque `Must` / `Custom` / `ErrorOverrides` flow without an explicit hint; a metadata schema is dropped because a user-defined property uses an unsupported complex type and falls back to an unconstrained schema. -- **Info** — the generator skipped something the user might want to know about, but the skip is design-correct. Triggers: a check chain nested inside control flow was not analyzed; a marked validator yielded zero error codes (likely a misconfiguration but not necessarily wrong). -- **Hidden / no diagnostic** — successful recognition; constant-argument resolution succeeded; no opaque flows present; all enclosing types were partial. Silence is the success signal. +- **Error** — the marked validator cannot be processed at all or the generated endpoint schema would be ambiguous. The generator skips emitting a partial declaration for it when the validator shape itself is unsupported. Triggers: validator class is not `partial`; validator is nested; validator or an enclosing type is generic; validator does not directly inherit from `Validator` or `Validator`; a custom validator base class sits between the marked validator and `Validator` / `Validator`; `[GeneratePortableValidationOpenApi]` is applied to an `AsyncValidator<...>`; user-defined check or definition has a malformed attribute combination (e.g. `[ValidationRuleMetadata(sourceArgument: "...")]` referencing a parameter that does not exist); two rules used by the same marked validator claim the same error code with incompatible metadata contracts. +- **Warning** — the validator can be processed, but the generated schema is weaker than the user probably expects. Triggers: opaque `Must` / `Custom` / `ErrorOverrides` flow without an explicit hint; a check chain nested inside control flow was not analyzed; a metadata schema is dropped because a user-defined property uses an unsupported complex type and falls back to an unconstrained schema. +- **Info** — the generator skipped something the user might want to know about, but the skip is design-correct and does not make the schema falsely exhaustive. Triggers: a marked validator yielded zero error codes (likely a misconfiguration but not necessarily wrong). +- **Hidden / no diagnostic** — successful recognition; constant-argument resolution succeeded; no opaque flows present; the validator shape is supported. Silence is the success signal. Diagnostic ids use the `LPRSG` prefix (Light.PortableResults Source Generator) and live in a single contiguous range (`LPRSG0001`–`LPRSG00xx`). Ids are stable so users can configure them in `.editorconfig`. Every diagnostic carries a help link in the generator's documentation describing the supported workarounds. @@ -315,19 +355,23 @@ A pre-build CLI / MSBuild task is intentionally **not** chosen. CLI tools are ap Tests should cover both generator behavior and integrated OpenAPI output: - generated code for a validator similar to `NewMovieRatingValidator` -- diagnostics for non-partial marked validators (and non-partial enclosing types) +- diagnostics for unsupported marked validators: non-partial, nested, generic, indirect inheritance, and async-validator shapes - diagnostics for unsupported `Must` / `Custom` patterns -- diagnostics for check chains nested inside control flow (`if`, `switch`, `foreach`, etc.) — the chain is skipped, the diagnostic is emitted +- diagnostics for malformed or conflicting custom rule/error-contract annotations +- warning diagnostics for check chains nested inside control flow (`if`, `switch`, `foreach`, etc.) — the chain is skipped, the diagnostic is emitted, and users can resolve it with explicit emitted-error hints or unknown-error opt-in - built-in typed helper generation for `IsInRange(1, 5)` and similar comparison/range checks - fixed metadata built-ins such as `HasLengthInRange(10, 1000)` - user-defined check methods with annotated error definitions and metadata mappings - explicit unknown-error opt-in producing `AllowUnknownErrorCodes()` - generated Minimal API endpoint metadata producing the same `oneOf` / metadata schemas as manual builder calls -- response-level examples appear when call-site arguments are constant; the example body matches the runtime JSON shape for the active `ValidationProblemSerializationFormat`; no example is emitted when arguments are not constant +- a single response-level example appears when at least one call site can contribute a complete example entry; the example body matches the runtime JSON shape for the active `ValidationProblemSerializationFormat`; metadata-bearing call sites with non-constant arguments are omitted from that example while their schemas remain documented - example `target` strings are derived from `context.Check(dto.Property)` member-access arguments, respect `[JsonPropertyName]` overrides and the configured `JsonNamingPolicy`, and fall back to `null` for complex targets - envelope-level `category: "Validation"` example appears on the canonical envelope schema and reflects user overrides - the analysis pass returns equal DTOs across runs for unchanged input, so the incremental pipeline caches correctly (in particular, the DTO contains no `ISymbol` references) +- cancellation is honored by the analysis pass when cancellation is requested during syntax or semantic-model work - a NativeAOT-oriented generated source assertion that no reflection/schema-exporter APIs appear in generated code +- generated source uses only its controlled using block or fully qualified names and does not rely on consumer usings +- a package-consumer test verifies that the packed `Light.PortableResults.Validation.OpenApi` package brings the generator along as an analyzer asset and that the analyzer loads without runtime assembly reference failures Snapshot tests are appropriate for generated source (`Verify.SourceGenerators` is the recommended harness). The tests must also compile generated output and assert the resulting OpenAPI document so the generator cannot drift from the transformer contract. A baseline test for the sample compares the source-generated document against the previously manually configured one and asserts non-regression on schema/code/`oneOf` content; the response-level examples added by source generation are an expected addition over the baseline, not a difference the test should reject. @@ -354,17 +398,18 @@ Concrete sample changes required: - The Scalar UI (`/docs`) and Swagger UI (`/swagger/index.html`) endpoints both render the source-generated document with concrete response-level example bodies that include `lowerBoundary: 1`, `upperBoundary: 5`, the length-range bounds, and the envelope's `category: "Validation"`. A short README section calls out the visible difference and notes any remaining rendering divergence between the two UIs (Scalar and Swashbuckle's UI handle OpenAPI 3.x examples slightly differently; pin which behavior is canonical). - A document-equivalence test in the test suite (not in the sample) compares the generated document against the pre-source-gen baseline so the swap is provably non-breaking. -The docs should clearly explain the supported subset and the escape hatches. The most important messages are: source generation is precise when validation rules are explicit and annotated; arbitrary delegates and imperative custom validation require explicit documentation hints; and the analysis only sees top-level statements in the validation method body. +The docs should clearly explain the supported subset and the escape hatches. The most important messages are: source generation is precise when validation rules are explicit and annotated; arbitrary delegates and imperative custom validation require explicit documentation hints; automatic source-null errors emitted before `PerformValidation` are not generated in v1; and the analysis only sees top-level statements in the validation method body. ### Scope Boundaries -This plan does not attempt to infer arbitrary C# semantics or execute validators at build time. It does not analyze checks nested inside control-flow constructs; those are skipped with a diagnostic. It does not replace the manual `ProducesPortableValidationProblem(...)` builder APIs; generated contracts compose with them. It does not require every validator in an application to opt in. It does not change runtime validation behavior or the wire format. +This plan does not attempt to infer arbitrary C# semantics or execute validators at build time. It does not analyze checks nested inside control-flow constructs; those are skipped with a warning diagnostic. It does not replace the manual `ProducesPortableValidationProblem(...)` builder APIs; generated contracts compose with them. It does not require every validator in an application to opt in. It does not change runtime validation behavior or the wire format. Out of scope for this iteration, intentionally deferred to keep the first generator landing small: - **`AsyncValidator` and `PerformValidationAsync`** — async validators produce a diagnostic when marked. - **MVC integration** — Minimal APIs only; the generated static contract is reusable from MVC in a follow-up plan. - **`ErrorOverrides`** — chains that override the default error code or metadata are treated as opaque. +- **Automatic source-null validation** — source-null errors emitted by the validator base class before `PerformValidation` runs are not inferred. Use explicit `IsNotNull()` rules or manual endpoint configuration when these errors should appear in OpenAPI. - **Complex target-string inference** — chained member access, indexers, and computed targets are out of scope. The simple `context.Check(dto.Property)` case is supported in v1. - **Child-validator composition** — validators invoking nested validators. - **Collection-item validation.** From c4c5fcfd06a327d1aa68bb944603107d0e8d6061 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 2 May 2026 12:27:11 +0200 Subject: [PATCH 04/17] chore: rename OpenAPI source generation plan to 0043 Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 2 +- ...i-source-generation.md => 0043-openapi-source-generation.md} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename ai-plans/{0042-openapi-source-generation.md => 0043-openapi-source-generation.md} (100%) diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 98e03cd..f09ad6d 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -56,7 +56,7 @@ - + diff --git a/ai-plans/0042-openapi-source-generation.md b/ai-plans/0043-openapi-source-generation.md similarity index 100% rename from ai-plans/0042-openapi-source-generation.md rename to ai-plans/0043-openapi-source-generation.md From 679cc773ebcc6cbcddf4ad94de15233969aa0318 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 6 May 2026 18:33:54 +0200 Subject: [PATCH 05/17] feat: initial implementation of OpenAPI Source Generator Signed-off-by: Kenny Pflug --- Directory.Packages.props | 2 + Light.PortableResults.slnx | 2 + README.md | 52 +- ai-plans/0043-openapi-source-generation.md | 56 +- .../Benchmarks/FlatDtoValidationBenchmarks.cs | 2 +- benchmarks/Benchmarks/packages.lock.json | 38 +- .../GetMovies/GetMoviesEndpoint.cs | 2 +- .../NativeAotMovieRating.csproj | 5 + .../NewMovieRating/NewMovieRatingEndpoint.cs | 9 +- .../NewMovieRating/NewMovieRatingValidator.cs | 6 +- ...rtableResultsOpenApiDocumentTransformer.cs | 185 +++- .../PortableOpenApiBuilderUtilities.cs | 18 + .../PortableOpenApiErrorExampleEntry.cs | 101 ++ ...rtableOpenApiErrorResponseAttributeBase.cs | 5 + .../PortableProblemOpenApiBuilder.cs | 17 + ...PortableValidationProblemOpenApiBuilder.cs | 17 + .../Schemas/PortableResultsOpenApiSchemas.cs | 3 +- .../DiagnosticDescriptors.cs | 100 ++ .../KnownTypeNames.cs | 24 + ...Validation.OpenApi.SourceGeneration.csproj | 21 + .../PortableValidationOpenApiGenerator.cs | 42 + .../ValidatorOpenApiAnalyzer.cs | 891 ++++++++++++++++++ .../ValidatorOpenApiEmitter.cs | 341 +++++++ .../ValidatorOpenApiModels.cs | 285 ++++++ .../packages.lock.json | 155 +++ ...BuiltInValidationErrorBuilderExtensions.cs | 259 ++++- ...eratePortableValidationOpenApiAttribute.cs | 15 + .../IPortableValidationOpenApiContract.cs | 14 + ....PortableResults.Validation.OpenApi.csproj | 11 + ...ableValidationOpenApiErrorHintAttribute.cs | 39 + ...ionOpenApiRouteHandlerBuilderExtensions.cs | 38 + .../Checks.Comparable.cs | 41 +- .../Checks.Count.cs | 18 + .../Checks.Decimals.cs | 8 + .../Checks.Empty.cs | 8 + .../Checks.Enums.cs | 4 + .../Checks.Equality.cs | 4 + .../Checks.Null.cs | 2 + .../Checks.Strings.cs | 14 + ...InValidationErrorDefinitions.Comparable.cs | 36 +- .../Definitions/ValidationRuleAttributes.cs | 195 ++++ .../Messaging/ValidationErrorTemplates.cs | 16 +- ...tion.OpenApi.SourceGeneration.Tests.csproj | 31 + ...PortableValidationOpenApiGeneratorTests.cs | 285 ++++++ .../packages.lock.json | 336 +++++++ .../xunit.runner.json | 5 + ...eratedValidationOpenApiIntegrationTests.cs | 97 ++ ...bleResults.Validation.OpenApi.Tests.csproj | 5 + .../BuiltInRuleFamilyWorkflowTests.cs | 4 +- .../CheckOverloadCoverageTests.cs | 16 +- .../CheckShortCircuitCoverageTests.cs | 8 +- .../ValidationErrorDefinitionTests.cs | 42 +- ...te-validation-openapi-source-generation.sh | 99 ++ 53 files changed, 3835 insertions(+), 194 deletions(-) create mode 100644 src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/PortableValidationOpenApiGenerator.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/packages.lock.json create mode 100644 src/Light.PortableResults.Validation.OpenApi/GeneratePortableValidationOpenApiAttribute.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/IPortableValidationOpenApiContract.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorHintAttribute.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiRouteHandlerBuilderExtensions.cs create mode 100644 src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests.csproj create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/packages.lock.json create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/xunit.runner.json create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs create mode 100755 tests/package-consumer/validate-validation-openapi-source-generation.sh diff --git a/Directory.Packages.props b/Directory.Packages.props index 91e322f..b927f12 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,8 @@ + + diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index f09ad6d..f5b362e 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -82,6 +82,7 @@ + @@ -93,6 +94,7 @@ + diff --git a/README.md b/README.md index 4b5111b..7fa72bb 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ public sealed class MovieRatingValidator : Validator dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace(); - context.Check(dto.Rating).IsInBetween(1, 5); + context.Check(dto.Rating).IsInRange(1, 5); // Use the checkpoint to determine if validation errors were attached to // to the ValidationContext during this method call. The checkpoint will @@ -813,7 +813,7 @@ public sealed class LightPortableResultsMovieRatingDtoValidator : Validator ) { var title = context.Check(dto.Title).IsNotNullOrWhiteSpace(); - context.Check(dto.ReleaseYear).IsInBetween(1888, DateTime.UtcNow.Year); + context.Check(dto.ReleaseYear).IsInRange(1888, DateTime.UtcNow.Year); var directorName = context.Check(dto.DirectorName).IsNotNullOrWhiteSpace(); if (checkpoint.HasNewErrors) @@ -995,7 +995,7 @@ public sealed class AddMovieRatingValidator : AsyncValidator context.Check(dto.MovieId).IsNotEmpty(); dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace(); dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); - context.Check(dto.Rating).IsInBetween(1, 5); + context.Check(dto.Rating).IsInRange(1, 5); // Only hit the database if the synchronous checks passed if (!checkpoint.HasNewErrors) @@ -1075,7 +1075,7 @@ public sealed class MovieSearchService var context = _contextFactory.CreateValidationContext(); var normalizedQuery = context.Check(query).IsNotNullOrWhiteSpace(); context.Check(page).IsGreaterThanOrEqualTo(1); - context.Check(pageSize).IsInBetween(1, 100); + context.Check(pageSize).IsInRange(1, 100); if (context.HasErrors) { @@ -1129,7 +1129,7 @@ public sealed class AddMovieRatingValidator : AsyncValidator context.Check(dto.MovieId).IsNotEmpty(shortCircuitOnError: true); dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace(); dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); - context.Check(dto.Rating).IsInBetween(1, 5); + context.Check(dto.Rating).IsInRange(1, 5); if (!checkpoint.HasNewErrors) { @@ -1317,7 +1317,7 @@ public sealed class EmailSenderOptionsValidator : Validator ) { context.Check(options.Host).IsNotNullOrWhiteSpace(); - context.Check(options.Port).IsInBetween(1, 65535); + context.Check(options.Port).IsInRange(1, 65535); context.Check(options.ApiKey).IsNotNullOrWhiteSpace(); return checkpoint.ToValidatedValue(options); } @@ -1429,7 +1429,43 @@ app.MapGet("/api/movies", GetMovies) ); ``` -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. +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 `IsInRange(1, 5)` while still reusing global schemas for shape-fixed codes. + +### Validation OpenAPI source generation + +`Light.PortableResults.Validation.OpenApi` also includes an opt-in source generator for Minimal API validation responses. Mark a synchronous validator with `[GeneratePortableValidationOpenApi]`, make it `partial`, and use `ProducesPortableValidationProblemFor(...)` on the endpoint: + +```csharp +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; + +[GeneratePortableValidationOpenApi] +public sealed partial class AddMovieRatingValidator : Validator +{ + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieRatingDto dto + ) + { + context.Check(dto.Id).IsNotEmpty(); + context.Check(dto.Comment).HasLengthIn(10, 1000); + context.Check(dto.Rating).IsInRange(1, 5); + return checkpoint.ToValidatedValue(dto); + } +} + +app.MapPut("/api/movieRatings", AddMovieRating) + .ProducesPortableValidationProblemFor( + configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich) + ); +``` + +The generated contract calls the same builder APIs you would write by hand, then the endpoint's `configure` callback runs afterward. This means you can still set the validation format, add manual metadata contracts, or call `AllowUnknownErrorCodes()` for errors that are outside the validator's documentable rules. + +The generator analyzes top-level `context.Check(...).Rule(...)` chains in synchronous `Validator` and `Validator` implementations. It supports built-in annotated rules, assignments that consume a checked value, explicit error hints via `[PortableValidationOpenApiErrorHint]`, and user-defined check methods annotated with `[ValidationRule]` plus optional `[ValidationErrorContract]` metadata definitions. Checks inside `if`, `switch`, loops, lambdas, local functions, `try`, or `using` blocks are skipped with a warning; lift the check to a top-level statement or add explicit hints when those errors must appear in the OpenAPI schema. + +When metadata arguments are compile-time constants, such as `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`, the generated contract also contributes a response-level OpenAPI example. Scalar and Swagger UI show these concrete values in the validation problem example body. Non-constant metadata arguments still get documented schemas, but that specific call site is omitted from the example. Delegate-based `Must(...)`, `Custom(...)`, `ErrorOverrides`, async validators, source-null errors emitted before `PerformValidation`, child validators, and complex target inference are intentionally outside the first-generation analysis. Register reusable per-error-code metadata contracts once in DI by passing them to `AddPortableResultsOpenApi(...)`: diff --git a/ai-plans/0043-openapi-source-generation.md b/ai-plans/0043-openapi-source-generation.md index 72318b6..495916a 100644 --- a/ai-plans/0043-openapi-source-generation.md +++ b/ai-plans/0043-openapi-source-generation.md @@ -17,34 +17,34 @@ Two pre-1.0 alignment items in `Light.PortableResults.Validation` are folded int ## Acceptance Criteria -- [ ] A new source-generator project is added for validation OpenAPI generation. It targets **`netstandard2.0`** because Roslyn analyzers and source generators are loaded into the C# compiler host and must comply with that contract; this is a hard requirement, not a style choice. The project is packaged as a Roslyn analyzer, uses `LangVersion=latest` (with `PolySharp` or hand-written shims for newer language features as needed), and does not add runtime dependencies to applications beyond the existing validation/OpenAPI packages. -- [ ] The generator project does not reference the validation or OpenAPI runtime assemblies as normal assembly references. It resolves public APIs by metadata name and uses linked/shared source only for small constant-only files where sharing is materially safer than duplicating strings. Runtime implementation files are not linked into the generator. -- [ ] The generator is implemented as an `IIncrementalGenerator` and uses `ForAttributeWithMetadataName(...)` to discover marked validators, so unrelated source changes do not invalidate the generation pipeline. Every value flowing through the pipeline implements value-equality (records or `EquatableArray`) so cached steps remain shareable. -- [ ] The analysis pass is structured as a pure function from `(Compilation, INamedTypeSymbol, CancellationToken)` to a plain DTO describing the generated builder calls. The `IIncrementalGenerator` glue is a thin adapter on top, so the analysis is testable directly without driving the full generator harness and reusable for a future MVC analyzer. The analysis honors cancellation throughout syntax and semantic-model work. -- [ ] Public generator-facing attributes are added without violating layering: validation-rule and validation-error-contract attributes live in the validation layer and do not reference `Microsoft.OpenApi`; OpenAPI-specific opt-in attributes live in `Light.PortableResults.Validation.OpenApi`. -- [ ] Built-in validation checks and built-in validation error definitions are annotated so the generator can identify their error code, metadata shape, and method-argument-to-metadata bindings without hard-coded method-name tables. -- [ ] The comparative-range check methods are renamed from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` so method names match `ValidationErrorCodes.InRange` / `NotInRange`. All callers in the solution (sample, tests, internal usages) are updated. This is a breaking change accepted pre-1.0. -- [ ] A public static-abstract contract interface is added in `Light.PortableResults.Validation.OpenApi`, allowing generated partial validator classes to expose `ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)` without reflection. -- [ ] A Minimal API helper such as `ProducesPortableValidationProblemFor(...)` is added. It calls the generated validator contract and then applies any caller-supplied manual builder configuration. -- [ ] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial`, non-nested, non-generic, and a direct subclass of `Validator` or `Validator`. Nested validators, open or closed generic validator types, generic enclosing types, indirect validator inheritance, custom validator base classes, and otherwise unsupported validators produce clear diagnostics. The diagnostic for custom/indirect bases explicitly tells users that v1 requires directly inheriting from `Validator` or `Validator`. This keeps the first implementation's emitted partial-type shape intentionally simple. -- [ ] The generator supports synchronous `Validator` and `Validator` by inspecting the `PerformValidation` method body. `AsyncValidator` / `AsyncValidator` are explicitly out of scope for this plan; marking an async validator with the OpenAPI generation attribute produces a diagnostic that points at the synchronous-validator restriction. -- [ ] The generator recognizes direct `ValidationContext.Check(...)` calls and fluent chains of supported check extension methods. It supports both standalone calls and assignments that consume a `ValidatedValue` returned by a check chain. -- [ ] The generator analyzes only **top-level statements** in the validation method body. Check expressions nested inside `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambdas, or local functions are skipped for schema generation and produce a warning diagnostic that suggests lifting the check out of the control-flow construct, adding explicit emitted-error hints, or accepting the gap via `AllowUnknownErrorCodes()`. Local variable declarations and the trailing `checkpoint.ToValidatedValue(...)` are silently allowed. -- [ ] The generator derives built-in no-metadata and fixed-metadata errors as `WithErrorCodes(...)` calls and derives built-in typed comparison/range errors as the existing typed helper calls such as `WithInRangeError()`. -- [ ] The generator supports user-defined validation error definitions and user-defined check extension methods when they are explicitly annotated with generator-facing metadata that identifies the error code and metadata schema. -- [ ] Generator-facing attributes use source-generator-friendly constructor shapes only: primitive values, strings, `Type`, enums, and arrays of those values. Attribute models do not require interpreting arbitrary object graphs or runtime-created values. -- [ ] Constant-valued arguments reach the generated OpenAPI documents as one concrete validator-level response example per generated endpoint response. The generator inspects each metadata-bound argument with `SemanticModel.GetConstantValue(...)`. When a call site's required metadata values resolve to constants, that call site contributes an error entry to the endpoint's example body; when they do not, that call site is omitted from the example and no diagnostic is raised. The schema/code documentation remains complete. The validation OpenAPI bridge supplies envelope-level constants (e.g. `category: "Validation"`) once at registration time. -- [ ] The `Light.PortableResults.Validation.OpenApi` package is extended with the public APIs needed to attach response-level examples: optional example-entry parameters on the existing typed helpers (`WithInRangeError`, `WithGreaterThanError`, etc.), a general-purpose `WithErrorExample(...)` helper for codes without typed shortcuts, and document-transformer support that composes endpoint-declared example entries into one `OpenApiResponse.Content[mediaType].Examples` body as a `JsonNode` tree matching the active `ValidationProblemSerializationFormat`. `RegisterBuiltInValidationErrors()` attaches the envelope-level `category: "Validation"` schema example with an override hook for users who change the default category. -- [ ] `Must(...)`, `Predicate`, and delegate-based validation are treated as opaque unless the failing definition or wrapper method is statically documentable. Unsupported opaque flows produce diagnostics and require an explicit opt-in to unknown errors or explicit emitted-error hints. -- [ ] `Custom(...)` is treated as opaque by default because it can emit zero, one, or many errors. A documented validator that uses `Custom(...)` must either provide explicit emitted-error hints or opt into unknown errors. -- [ ] Generated code is NativeAOT-safe: it does not use reflection, does not instantiate arbitrary validation error definitions for documentation, and does not call ASP.NET Core's runtime schema exporter for generated validation metadata. -- [ ] Generated source uses a controlled, deterministic using block and does not depend on the consumer project's implicit usings, global usings, aliases, or local using directives. -- [ ] Generated source hint names are deterministic and stable across machines and builds, based on the validator's fully qualified metadata name. This keeps IDE behavior and snapshot tests stable. -- [ ] Automatic source-null errors emitted before `PerformValidation` runs are explicitly out of scope. The plan documents that users should add an explicit top-level `IsNotNull()` rule or manual OpenAPI builder configuration when they want source-null errors documented in v1. -- [ ] The `NativeAotMovieRating` sample is updated end to end: `NewMovieRatingValidator` becomes `partial` and is annotated with `[GeneratePortableValidationOpenApi]`; `NewMovieRatingEndpoint` replaces its manual `WithErrorCodes(...)` / `WithInRangeError()` configuration with `ProducesPortableValidationProblemFor(...)`; an automated test asserts that the resulting OpenAPI document preserves everything the manually configured document had — schemas, error codes, `oneOf` structure, and exhaustiveness flags — after canonical key ordering, while the new document is *additionally* allowed to carry response-level examples that the baseline lacks (the examples are the point of the swap, not a regression); and the rendered Scalar (`/docs`) and Swagger UI (`/swagger/index.html`) outputs both show concrete example values for at least the `InRange` and `LengthInRange` error metadata. -- [ ] Automated tests are added for generator output, diagnostics, Minimal API integration, built-in rule coverage, user-defined rule support, control-flow-skipped checks, constant-vs-non-constant example handling, opaque `Must` / `Custom` handling, and NativeAOT-safe generated code shape. Snapshot tests cover emitted source; document-generation tests cover the resulting OpenAPI document so the generator cannot drift from the transformer contract. -- [ ] Packaging is validated with a local NuGet-package consumer test: `Light.PortableResults.Validation.OpenApi` is packed, consumed from a local feed by a small project without project references to the source tree, and verified to load and run the bundled analyzer/source generator. If this is too slow for the normal unit-test loop, it may live behind a dedicated integration/package test target. -- [ ] Documentation is updated to explain the generator opt-in model, supported validation patterns, the top-level-statements-only analysis boundary, required annotations for custom rules, the constant-argument requirement for examples, limitations around delegates and imperative custom validation, and how to mix generated and manual endpoint OpenAPI configuration. +- [x] A new source-generator project is added for validation OpenAPI generation. It targets **`netstandard2.0`** because Roslyn analyzers and source generators are loaded into the C# compiler host and must comply with that contract; this is a hard requirement, not a style choice. The project is packaged as a Roslyn analyzer, uses `LangVersion=latest` (with `PolySharp` or hand-written shims for newer language features as needed), and does not add runtime dependencies to applications beyond the existing validation/OpenAPI packages. +- [x] The generator project does not reference the validation or OpenAPI runtime assemblies as normal assembly references. It resolves public APIs by metadata name and uses linked/shared source only for small constant-only files where sharing is materially safer than duplicating strings. Runtime implementation files are not linked into the generator. +- [x] The generator is implemented as an `IIncrementalGenerator` and uses `ForAttributeWithMetadataName(...)` to discover marked validators, so unrelated source changes do not invalidate the generation pipeline. Every value flowing through the pipeline implements value-equality (records or `EquatableArray`) so cached steps remain shareable. +- [x] The analysis pass is structured as a pure function from `(Compilation, INamedTypeSymbol, CancellationToken)` to a plain DTO describing the generated builder calls. The `IIncrementalGenerator` glue is a thin adapter on top, so the analysis is testable directly without driving the full generator harness and reusable for a future MVC analyzer. The analysis honors cancellation throughout syntax and semantic-model work. +- [x] Public generator-facing attributes are added without violating layering: validation-rule and validation-error-contract attributes live in the validation layer and do not reference `Microsoft.OpenApi`; OpenAPI-specific opt-in attributes live in `Light.PortableResults.Validation.OpenApi`. +- [x] Built-in validation checks and built-in validation error definitions are annotated so the generator can identify their error code, metadata shape, and method-argument-to-metadata bindings without hard-coded method-name tables. +- [x] The comparative-range check methods are renamed from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` so method names match `ValidationErrorCodes.InRange` / `NotInRange`. All callers in the solution (sample, tests, internal usages) are updated. This is a breaking change accepted pre-1.0. +- [x] A public static-abstract contract interface is added in `Light.PortableResults.Validation.OpenApi`, allowing generated partial validator classes to expose `ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)` without reflection. +- [x] A Minimal API helper such as `ProducesPortableValidationProblemFor(...)` is added. It calls the generated validator contract and then applies any caller-supplied manual builder configuration. +- [x] Marking a validator with the OpenAPI generation attribute requires the validator class to be `partial`, non-nested, non-generic, and a direct subclass of `Validator` or `Validator`. Nested validators, open or closed generic validator types, generic enclosing types, indirect validator inheritance, custom validator base classes, and otherwise unsupported validators produce clear diagnostics. The diagnostic for custom/indirect bases explicitly tells users that v1 requires directly inheriting from `Validator` or `Validator`. This keeps the first implementation's emitted partial-type shape intentionally simple. +- [x] The generator supports synchronous `Validator` and `Validator` by inspecting the `PerformValidation` method body. `AsyncValidator` / `AsyncValidator` are explicitly out of scope for this plan; marking an async validator with the OpenAPI generation attribute produces a diagnostic that points at the synchronous-validator restriction. +- [x] The generator recognizes direct `ValidationContext.Check(...)` calls and fluent chains of supported check extension methods. It supports both standalone calls and assignments that consume a `ValidatedValue` returned by a check chain. +- [x] The generator analyzes only **top-level statements** in the validation method body. Check expressions nested inside `if`, `else`, `switch`, `for`, `foreach`, `while`, `do`, `try`, `using`, lambdas, or local functions are skipped for schema generation and produce a warning diagnostic that suggests lifting the check out of the control-flow construct, adding explicit emitted-error hints, or accepting the gap via `AllowUnknownErrorCodes()`. Local variable declarations and the trailing `checkpoint.ToValidatedValue(...)` are silently allowed. +- [x] The generator derives built-in no-metadata and fixed-metadata errors as `WithErrorCodes(...)` calls and derives built-in typed comparison/range errors as the existing typed helper calls such as `WithInRangeError()`. +- [x] The generator supports user-defined validation error definitions and user-defined check extension methods when they are explicitly annotated with generator-facing metadata that identifies the error code and metadata schema. +- [x] Generator-facing attributes use source-generator-friendly constructor shapes only: primitive values, strings, `Type`, enums, and arrays of those values. Attribute models do not require interpreting arbitrary object graphs or runtime-created values. +- [x] Constant-valued arguments reach the generated OpenAPI documents as one concrete validator-level response example per generated endpoint response. The generator inspects each metadata-bound argument with `SemanticModel.GetConstantValue(...)`. When a call site's required metadata values resolve to constants, that call site contributes an error entry to the endpoint's example body; when they do not, that call site is omitted from the example and no diagnostic is raised. The schema/code documentation remains complete. The validation OpenAPI bridge supplies envelope-level constants (e.g. `category: "Validation"`) once at registration time. +- [x] The `Light.PortableResults.Validation.OpenApi` package is extended with the public APIs needed to attach response-level examples: optional example-entry parameters on the existing typed helpers (`WithInRangeError`, `WithGreaterThanError`, etc.), a general-purpose `WithErrorExample(...)` helper for codes without typed shortcuts, and document-transformer support that composes endpoint-declared example entries into one `OpenApiResponse.Content[mediaType].Examples` body as a `JsonNode` tree matching the active `ValidationProblemSerializationFormat`. `RegisterBuiltInValidationErrors()` attaches the envelope-level `category: "Validation"` schema example with an override hook for users who change the default category. +- [x] `Must(...)`, `Predicate`, and delegate-based validation are treated as opaque unless the failing definition or wrapper method is statically documentable. Unsupported opaque flows produce diagnostics and require an explicit opt-in to unknown errors or explicit emitted-error hints. +- [x] `Custom(...)` is treated as opaque by default because it can emit zero, one, or many errors. A documented validator that uses `Custom(...)` must either provide explicit emitted-error hints or opt into unknown errors. +- [x] Generated code is NativeAOT-safe: it does not use reflection, does not instantiate arbitrary validation error definitions for documentation, and does not call ASP.NET Core's runtime schema exporter for generated validation metadata. +- [x] Generated source uses a controlled, deterministic using block and does not depend on the consumer project's implicit usings, global usings, aliases, or local using directives. +- [x] Generated source hint names are deterministic and stable across machines and builds, based on the validator's fully qualified metadata name. This keeps IDE behavior and snapshot tests stable. +- [x] Automatic source-null errors emitted before `PerformValidation` runs are explicitly out of scope. The plan documents that users should add an explicit top-level `IsNotNull()` rule or manual OpenAPI builder configuration when they want source-null errors documented in v1. +- [x] The `NativeAotMovieRating` sample is updated end to end: `NewMovieRatingValidator` becomes `partial` and is annotated with `[GeneratePortableValidationOpenApi]`; `NewMovieRatingEndpoint` replaces its manual `WithErrorCodes(...)` / `WithInRangeError()` configuration with `ProducesPortableValidationProblemFor(...)`; an automated test asserts that the resulting OpenAPI document preserves everything the manually configured document had — schemas, error codes, `oneOf` structure, and exhaustiveness flags — after canonical key ordering, while the new document is *additionally* allowed to carry response-level examples that the baseline lacks (the examples are the point of the swap, not a regression); and the rendered Scalar (`/docs`) and Swagger UI (`/swagger/index.html`) outputs both show concrete example values for at least the `InRange` and `LengthInRange` error metadata. +- [x] Automated tests are added for generator output, diagnostics, Minimal API integration, built-in rule coverage, user-defined rule support, control-flow-skipped checks, constant-vs-non-constant example handling, opaque `Must` / `Custom` handling, and NativeAOT-safe generated code shape. Snapshot tests cover emitted source; document-generation tests cover the resulting OpenAPI document so the generator cannot drift from the transformer contract. +- [x] Packaging is validated with a local NuGet-package consumer test: `Light.PortableResults.Validation.OpenApi` is packed, consumed from a local feed by a small project without project references to the source tree, and verified to load and run the bundled analyzer/source generator. If this is too slow for the normal unit-test loop, it may live behind a dedicated integration/package test target. +- [x] Documentation is updated to explain the generator opt-in model, supported validation patterns, the top-level-statements-only analysis boundary, required annotations for custom rules, the constant-argument requirement for examples, limitations around delegates and imperative custom validation, and how to mix generated and manual endpoint OpenAPI configuration. ## Technical Details diff --git a/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs b/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs index 366adda..d789f48 100644 --- a/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs +++ b/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs @@ -263,7 +263,7 @@ MovieRatingDto dto { context.Check(dto.Id).IsNotEmpty(); dto.Comment = context.Check(dto.Comment).IsNotNullOrWhiteSpace().HasLengthIn(10, 1000); - context.Check(dto.Rating).IsInBetween(1, 5); + context.Check(dto.Rating).IsInRange(1, 5); return checkpoint.ToValidatedValue(dto); } } diff --git a/benchmarks/Benchmarks/packages.lock.json b/benchmarks/Benchmarks/packages.lock.json index e7472f3..e160756 100644 --- a/benchmarks/Benchmarks/packages.lock.json +++ b/benchmarks/Benchmarks/packages.lock.json @@ -46,11 +46,6 @@ "resolved": "1.21.0", "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", "resolved": "4.14.0", @@ -59,15 +54,6 @@ "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Transitive", - "resolved": "4.14.0", - "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[4.14.0]" - } - }, "Microsoft.Diagnostics.NETCore.Client": { "type": "Transitive", "resolved": "0.2.510501", @@ -136,25 +122,25 @@ "light.portableresults.aspnetcore.minimalapis": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.5.0, )" } }, "light.portableresults.aspnetcore.mvc": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.4.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.5.0, )" } }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.4.0, )" + "Light.PortableResults": "[0.5.0, )" } }, "light.portableresults.validation": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.4.0, )" + "Light.PortableResults": "[0.5.0, )" } }, "Microsoft.Bcl.HashCode": { @@ -163,6 +149,22 @@ "resolved": "6.0.0", "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[3.11.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "CentralTransitive", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]" + } + }, "Ulid": { "type": "CentralTransitive", "requested": "[1.4.1, )", diff --git a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs index 58a026e..051ce31 100644 --- a/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs +++ b/samples/NativeAotMovieRating/GetMovies/GetMoviesEndpoint.cs @@ -49,7 +49,7 @@ private static async Task GetMovies( // parameters. This code might seem a little verbose, but you can easily put it in a static method and // call it from any endpoint implementing pagination. var validationContext = validationContextFactory.CreateValidationContext(); - validationContext.Check(take).IsInBetween(1, 40); + validationContext.Check(take).IsInRange(1, 40); if (lastKnownMovieId.HasValue) { validationContext.Check(lastKnownMovieId.Value, target: nameof(lastKnownMovieId)).IsNotEmpty( diff --git a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj index b6b8c05..e081e88 100644 --- a/samples/NativeAotMovieRating/NativeAotMovieRating.csproj +++ b/samples/NativeAotMovieRating/NativeAotMovieRating.csproj @@ -15,6 +15,11 @@ + diff --git a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs index ee17cb5..3bf1184 100644 --- a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingEndpoint.cs @@ -3,7 +3,6 @@ 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; @@ -22,15 +21,9 @@ public static void MapAddMovieRatingEndpoint(this WebApplication app) => "Validates the request and stores the movie rating. Returns the stored rating on success, or a rich Light.PortableResults problem details response on validation or lookup failures." ) .Produces() - .ProducesPortableValidationProblem( + .ProducesPortableValidationProblemFor( configure: x => x .UseFormat(ValidationProblemSerializationFormat.Rich) - .WithErrorCodes( - ValidationErrorCodes.NotEmpty, - ValidationErrorCodes.LengthInRange, - ValidationErrorCodes.NotNullOrWhiteSpace - ) - .WithInRangeError() ) .ProducesPortableProblem(); diff --git a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs index 9bd9483..87008ce 100644 --- a/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs +++ b/samples/NativeAotMovieRating/NewMovieRating/NewMovieRatingValidator.cs @@ -1,8 +1,10 @@ using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; namespace NativeAotMovieRating.NewMovieRating; -public sealed class NewMovieRatingValidator : Validator +[GeneratePortableValidationOpenApi] +public sealed partial class NewMovieRatingValidator : Validator { public NewMovieRatingValidator(IValidationContextFactory validationContextFactory) : base(validationContextFactory) { } @@ -17,7 +19,7 @@ NewMovieRatingDto dto context.Check(dto.MovieId).IsNotEmpty(); dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace(); - context.Check(dto.Rating).IsInBetween(1, 5); + context.Check(dto.Rating).IsInRange(1, 5); return checkpoint.ToValidatedValue(dto); } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 05ba7ca..029fd16 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Light.PortableResults.AspNetCore.OpenApi.Schemas; +using Light.PortableResults.Http; using Light.PortableResults.Http.Writing; using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -182,19 +183,191 @@ CancellationToken cancellationToken } var response = GetOrCreateResponse(operation, responseGroup.Key.StatusCode); + var mediaType = new OpenApiMediaType + { + Schema = contributingSchemas.Count == 1 ? + contributingSchemas[0] : + new OpenApiSchema { AnyOf = contributingSchemas } + }; + ApplyResponseExample(mediaType, responseGroup.ToArray(), responseGroup.Key.StatusCode); SetResponseContent( response, responseGroup.Key.ContentType, - new OpenApiMediaType - { - Schema = contributingSchemas.Count == 1 ? - contributingSchemas[0] : - new OpenApiSchema { AnyOf = contributingSchemas } - } + mediaType ); } } + private void ApplyResponseExample( + OpenApiMediaType mediaType, + IReadOnlyList attributes, + int statusCode + ) + { + var errorAttribute = attributes + .OfType() + .FirstOrDefault(static attribute => attribute.ErrorExamples is { Length: > 0 }); + if (errorAttribute is null) + { + return; + } + + mediaType.Examples = new Dictionary(StringComparer.Ordinal) + { + ["ValidationProblem"] = new OpenApiExample + { + Summary = "Validation problem", + Value = CreateResponseExample(errorAttribute, statusCode) + } + }; + } + + private JsonObject CreateResponseExample( + PortableOpenApiErrorResponseAttributeBase attribute, + int statusCode + ) + { + var example = new JsonObject + { + ["type"] = ErrorCategory.Validation.GetTypeUri(), + ["title"] = ErrorCategory.Validation.GetTitle(), + ["status"] = statusCode, + ["detail"] = ErrorCategory.Validation.GetDetail() + }; + + if (attribute is ProducesPortableValidationProblemAttribute validationAttribute && + ResolveValidationProblemFormat(validationAttribute) == ValidationProblemSerializationFormat.AspNetCoreCompatible) + { + AddAspNetCoreCompatibleValidationExample(example, validationAttribute.ErrorExamples!); + } + else + { + AddRichErrorExample(example, attribute.ErrorExamples!); + } + + return example; + } + + private ValidationProblemSerializationFormat ResolveValidationProblemFormat( + ProducesPortableValidationProblemAttribute attribute + ) + { + return attribute.HasFormatOverride ? + attribute.Format : + _writeOptions.ValidationProblemSerializationFormat; + } + + private static void AddRichErrorExample( + JsonObject example, + IReadOnlyList entries + ) + { + var errors = new JsonArray(); + foreach (var entry in entries) + { + var error = new JsonObject + { + ["message"] = "Validation failed.", + ["code"] = entry.Code, + ["target"] = entry.Target, + ["category"] = ErrorCategory.Validation.ToString() + }; + AddMetadata(error, entry.Metadata); + errors.Add((JsonNode) error); + } + + example["errors"] = errors; + } + + private static void AddAspNetCoreCompatibleValidationExample( + JsonObject example, + IReadOnlyList entries + ) + { + var groupedErrors = new Dictionary(StringComparer.Ordinal); + var nextIndexByTarget = new Dictionary(StringComparer.Ordinal); + var errorDetails = new JsonArray(); + + foreach (var entry in entries) + { + var target = entry.Target ?? ""; + if (!groupedErrors.TryGetValue(target, out var messages)) + { + messages = []; + groupedErrors.Add(target, messages); + nextIndexByTarget.Add(target, 0); + } + + var index = nextIndexByTarget[target]; + nextIndexByTarget[target] = index + 1; + messages.Add((JsonNode?) JsonValue.Create("Validation failed.")); + + var detail = new JsonObject + { + ["target"] = target, + ["index"] = index, + ["code"] = entry.Code, + ["category"] = ErrorCategory.Validation.ToString() + }; + AddMetadata(detail, entry.Metadata); + errorDetails.Add((JsonNode) detail); + } + + var errors = new JsonObject(); + foreach (var (target, messages) in groupedErrors) + { + errors[target] = messages; + } + + example["errors"] = errors; + example["errorDetails"] = errorDetails; + } + + private static void AddMetadata( + JsonObject error, + IReadOnlyDictionary? metadata + ) + { + if (metadata is null || metadata.Count == 0) + { + return; + } + + var metadataObject = new JsonObject(); + foreach (var (key, value) in metadata) + { + metadataObject[key] = CreateJsonValue(value); + } + + error["metadata"] = metadataObject; + } + + private static JsonNode? CreateJsonValue(object? value) + { + return value switch + { + null => null, + string stringValue => JsonValue.Create(stringValue), + bool boolValue => JsonValue.Create(boolValue), + byte byteValue => JsonValue.Create(byteValue), + sbyte sbyteValue => JsonValue.Create(sbyteValue), + short shortValue => JsonValue.Create(shortValue), + ushort ushortValue => JsonValue.Create(ushortValue), + int intValue => JsonValue.Create(intValue), + uint uintValue => JsonValue.Create(uintValue), + long longValue => JsonValue.Create(longValue), + ulong ulongValue => JsonValue.Create(ulongValue), + float floatValue => JsonValue.Create(floatValue), + double doubleValue => JsonValue.Create(doubleValue), + decimal decimalValue => JsonValue.Create(decimalValue), + DateTime dateTimeValue => JsonValue.Create(dateTimeValue), + DateTimeOffset dateTimeOffsetValue => JsonValue.Create(dateTimeOffsetValue), + Guid guidValue => JsonValue.Create(guidValue), + Enum enumValue => JsonValue.Create(Convert.ToInt64(enumValue, CultureInfo.InvariantCulture)), + _ => JsonValue.Create(value.ToString()) + }; + } + private async Task CreateContributingSchemaAsync( OpenApiDocument document, OpenApiDocumentTransformerContext context, diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs index c0a65e1..280caac 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiBuilderUtilities.cs @@ -59,4 +59,22 @@ ErrorMetadataContract newValue combinedValues[^1] = newValue; return combinedValues; } + + internal static PortableOpenApiErrorExampleEntry[] AppendExamples( + PortableOpenApiErrorExampleEntry[]? existingValues, + PortableOpenApiErrorExampleEntry newValue + ) + { + ArgumentNullException.ThrowIfNull(newValue); + + if (existingValues is null) + { + return [newValue]; + } + + var combinedValues = new PortableOpenApiErrorExampleEntry[existingValues.Length + 1]; + Array.Copy(existingValues, combinedValues, existingValues.Length); + combinedValues[^1] = newValue; + return combinedValues; + } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs new file mode 100644 index 0000000..2893c5e --- /dev/null +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; + +namespace Light.PortableResults.AspNetCore.OpenApi; + +/// +/// Represents one documented error entry that can be composed into an OpenAPI response example. +/// +public sealed class PortableOpenApiErrorExampleEntry : IEquatable +{ + /// + /// Initializes a new instance of . + /// + public PortableOpenApiErrorExampleEntry( + string code, + string? target, + IReadOnlyDictionary? metadata + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + + Code = code; + Target = target; + Metadata = metadata; + } + + /// + /// Gets the validation error code. + /// + public string Code { get; } + + /// + /// Gets the validation target, or when the target could not be inferred. + /// + public string? Target { get; } + + /// + /// Gets the optional error metadata values. + /// + public IReadOnlyDictionary? Metadata { get; } + + /// + public bool Equals(PortableOpenApiErrorExampleEntry? other) + { + if (other is null) + { + return false; + } + + return string.Equals(Code, other.Code, StringComparison.Ordinal) && + string.Equals(Target, other.Target, StringComparison.Ordinal) && + MetadataEquals(Metadata, other.Metadata); + } + + /// + public override bool Equals(object? obj) => Equals(obj as PortableOpenApiErrorExampleEntry); + + /// + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Code, StringComparer.Ordinal); + hashCode.Add(Target, StringComparer.Ordinal); + if (Metadata is not null) + { + foreach (var (key, value) in Metadata) + { + hashCode.Add(key, StringComparer.Ordinal); + hashCode.Add(value); + } + } + + return hashCode.ToHashCode(); + } + + private static bool MetadataEquals( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right + ) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null || left.Count != right.Count) + { + return false; + } + + foreach (var (key, leftValue) in left) + { + if (!right.TryGetValue(key, out var rightValue) || !Equals(leftValue, rightValue)) + { + return false; + } + } + + return true; + } +} diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs index 5c0ec1a..72b8b1a 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorResponseAttributeBase.cs @@ -39,4 +39,9 @@ string contentType /// . /// public ErrorMetadataContract[]? InlineErrorMetadataContracts { get; set; } + + /// + /// Gets or sets example error entries that should be composed into the documented response example. + /// + public PortableOpenApiErrorExampleEntry[]? ErrorExamples { get; set; } } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index 577e939..45f9a15 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Microsoft.OpenApi; @@ -41,6 +42,22 @@ public PortableProblemOpenApiBuilder WithErrorCodes(params string[] codes) return this; } + /// + /// Adds an error entry that should appear in the response-level OpenAPI example for this endpoint. + /// + public PortableProblemOpenApiBuilder WithErrorExample( + string code, + string? target, + IReadOnlyDictionary? metadata = null + ) + { + _attribute.ErrorExamples = PortableOpenApiBuilderUtilities.AppendExamples( + _attribute.ErrorExamples, + new PortableOpenApiErrorExampleEntry(code, target, metadata) + ); + return this; + } + /// /// Keeps the schema non-exhaustive so undocumented error codes remain representable. /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 6dd60f8..9fb9437 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; using Light.PortableResults.Http.Writing; using Microsoft.OpenApi; @@ -43,6 +44,22 @@ public PortableValidationProblemOpenApiBuilder WithErrorCodes(params string[] co return this; } + /// + /// Adds an error entry that should appear in the response-level OpenAPI example for this endpoint. + /// + public PortableValidationProblemOpenApiBuilder WithErrorExample( + string code, + string? target, + IReadOnlyDictionary? metadata = null + ) + { + _attribute.ErrorExamples = PortableOpenApiBuilderUtilities.AppendExamples( + _attribute.ErrorExamples, + new PortableOpenApiErrorExampleEntry(code, target, metadata) + ); + return this; + } + /// /// Keeps the schema non-exhaustive so undocumented error codes remain representable. /// diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs index a8cd838..ebc063d 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Schemas/PortableResultsOpenApiSchemas.cs @@ -118,7 +118,8 @@ private static OpenApiSchema CreateErrorCategorySchema() Type = JsonSchemaType.String, Enum = Enum.GetNames(typeof(ErrorCategory)) .Select(static name => (JsonNode) JsonValue.Create(name)) - .ToList() + .ToList(), + Examples = [JsonValue.Create(ErrorCategory.Validation.ToString())] }; } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs new file mode 100644 index 0000000..874c7f2 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs @@ -0,0 +1,100 @@ +using Microsoft.CodeAnalysis; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +internal static class DiagnosticDescriptors +{ + private const string Category = "Light.PortableResults.Validation.OpenApi.SourceGeneration"; + private const string HelpLinkBase = + "https://github.com/feO2x/Light.PortableResults/blob/main/README.md#validation-openapi-source-generation"; + + public static readonly DiagnosticDescriptor ValidatorMustBePartial = new ( + "LPRSG0001", + "Validator must be partial", + "Validator '{0}' must be partial to receive generated PortableResults validation OpenAPI metadata", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor UnsupportedValidatorShape = new ( + "LPRSG0002", + "Validator shape is not supported", + "Validator '{0}' is not supported by validation OpenAPI source generation: {1}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor AsyncValidatorUnsupported = new ( + "LPRSG0003", + "Async validators are not supported", + "Validator '{0}' inherits from an async validator base; v1 supports only direct Validator or Validator bases", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor MissingPerformValidation = new ( + "LPRSG0004", + "PerformValidation must be declared on the marked validator", + "Validator '{0}' must declare a synchronous PerformValidation method directly on the marked partial class", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor NestedCheckSkipped = new ( + "LPRSG0005", + "Nested validation check was skipped", + "Validation check '{0}' is nested inside control flow and is not included in generated OpenAPI metadata; lift it to a top-level statement, add explicit hints, or allow unknown error codes", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor OpaqueValidationFlow = new ( + "LPRSG0006", + "Opaque validation flow is not documentable", + "Validation flow '{0}' cannot be inferred for generated OpenAPI metadata; use an annotated wrapper rule, add explicit hints, or allow unknown error codes", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor InvalidRuleMetadata = new ( + "LPRSG0007", + "Validation rule metadata is malformed", + "Validation rule '{0}' binds metadata to parameter '{1}', but that parameter is not available at the call site", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor NoDocumentedRules = new ( + "LPRSG0008", + "Validator produced no documentable validation rules", + "Validator '{0}' produced no documentable validation rules for generated OpenAPI metadata", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor InvalidErrorContract = new ( + "LPRSG0009", + "Validation error contract is malformed", + "Validation rule '{0}' references error definition '{1}', but it does not declare a matching validation error contract for code '{2}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs new file mode 100644 index 0000000..e29bd07 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs @@ -0,0 +1,24 @@ +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +internal static class KnownTypeNames +{ + public const string GenerateAttribute = + "Light.PortableResults.Validation.OpenApi.GeneratePortableValidationOpenApiAttribute"; + public const string ErrorHintAttribute = + "Light.PortableResults.Validation.OpenApi.PortableValidationOpenApiErrorHintAttribute"; + public const string ValidationRuleAttribute = + "Light.PortableResults.Validation.Definitions.ValidationRuleAttribute"; + public const string ValidationRuleMetadataAttribute = + "Light.PortableResults.Validation.Definitions.ValidationRuleMetadataAttribute"; + public const string ValidationErrorContractAttribute = + "Light.PortableResults.Validation.Definitions.ValidationErrorContractAttribute"; + public const string ValidationErrorMetadataContractAttribute = + "Light.PortableResults.Validation.Definitions.ValidationErrorMetadataContractAttribute"; + public const string Validator = "Light.PortableResults.Validation.Validator`1"; + public const string TransformingValidator = "Light.PortableResults.Validation.Validator`2"; + public const string AsyncValidator = "Light.PortableResults.Validation.AsyncValidator`1"; + public const string TransformingAsyncValidator = "Light.PortableResults.Validation.AsyncValidator`2"; + public const string ValidationContext = "Light.PortableResults.Validation.ValidationContext"; + public const string ErrorOverrides = "Light.PortableResults.Validation.ErrorOverrides"; + public const string JsonPropertyNameAttribute = "System.Text.Json.Serialization.JsonPropertyNameAttribute"; +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj new file mode 100644 index 0000000..74ebaf2 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + latest + enable + disable + false + false + Source generator for Light.PortableResults.Validation OpenAPI metadata. + $(NoWarn);RS1036;RS2008 + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/PortableValidationOpenApiGenerator.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/PortableValidationOpenApiGenerator.cs new file mode 100644 index 0000000..5524be5 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/PortableValidationOpenApiGenerator.cs @@ -0,0 +1,42 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Generates Minimal API OpenAPI metadata contracts for marked synchronous validators. +/// +[Generator] +public sealed class PortableValidationOpenApiGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var validators = context.SyntaxProvider.ForAttributeWithMetadataName( + KnownTypeNames.GenerateAttribute, + static (node, _) => node is ClassDeclarationSyntax, + static (attributeContext, cancellationToken) => + ValidatorOpenApiAnalyzer.Analyze( + attributeContext.SemanticModel.Compilation, + (INamedTypeSymbol) attributeContext.TargetSymbol, + cancellationToken + ) + ); + + context.RegisterSourceOutput( + validators, + static (sourceProductionContext, analysis) => + { + foreach (var diagnostic in analysis.Diagnostics) + { + sourceProductionContext.ReportDiagnostic(diagnostic); + } + + if (analysis.Source is not null) + { + sourceProductionContext.AddSource(analysis.HintName, analysis.Source); + } + } + ); + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs new file mode 100644 index 0000000..0d85c90 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -0,0 +1,891 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Analyzes marked validators and produces generated OpenAPI contract source. +/// +public static class ValidatorOpenApiAnalyzer +{ + private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + /// + /// Analyzes a validator symbol and returns diagnostics plus generated source when the validator is supported. + /// + public static ValidatorOpenApiAnalysis Analyze( + Compilation compilation, + INamedTypeSymbol validatorType, + CancellationToken cancellationToken + ) + { + var diagnostics = ImmutableArray.CreateBuilder(); + var hintName = CreateHintName(validatorType); + var validatorLocation = validatorType.Locations.FirstOrDefault(); + + if (!TryGetPrimaryClassDeclaration(validatorType, cancellationToken, out var classDeclaration)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.UnsupportedValidatorShape, + validatorLocation, + validatorType.Name, + "the class declaration could not be found" + ) + ); + return new ValidatorOpenApiAnalysis(hintName, null, diagnostics.ToImmutable()); + } + + ValidateValidatorShape(validatorType, classDeclaration, diagnostics, cancellationToken); + var hasShapeError = diagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + if (!TryGetPerformValidationMethod(validatorType, cancellationToken, out var performValidation, out var methodDeclaration)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.MissingPerformValidation, + validatorLocation, + validatorType.Name + ) + ); + hasShapeError = true; + } + + if (hasShapeError || performValidation is null || methodDeclaration is null) + { + return new ValidatorOpenApiAnalysis(hintName, null, diagnostics.ToImmutable()); + } + + var semanticModel = compilation.GetSemanticModel(methodDeclaration.SyntaxTree); + var sourceParameterName = performValidation.Parameters.Length >= 3 ? performValidation.Parameters[2].Name : null; + var rules = ImmutableArray.CreateBuilder(); + AnalyzePerformValidationBody( + semanticModel, + methodDeclaration, + sourceParameterName, + rules, + diagnostics, + cancellationToken + ); + + var hints = GetErrorHints(validatorType, performValidation).ToImmutableArray(); + var allowUnknownErrorCodes = GetAllowUnknownErrorCodes(validatorType); + if (rules.Count == 0 && hints.Length == 0 && !allowUnknownErrorCodes) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.NoDocumentedRules, + validatorLocation, + validatorType.Name + ) + ); + } + + var model = new ValidatorModel( + validatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + validatorType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + validatorType.ContainingNamespace.IsGlobalNamespace ? + null : + validatorType.ContainingNamespace.ToDisplayString(), + GetAccessibility(validatorType.DeclaredAccessibility), + validatorType.Name, + allowUnknownErrorCodes, + rules.ToImmutable(), + hints + ); + var source = ValidatorOpenApiEmitter.Emit(model); + return new ValidatorOpenApiAnalysis(hintName, source, diagnostics.ToImmutable()); + } + + private static void ValidateValidatorShape( + INamedTypeSymbol validatorType, + ClassDeclarationSyntax classDeclaration, + ICollection diagnostics, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var validatorLocation = classDeclaration.Identifier.GetLocation(); + + if (!classDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.ValidatorMustBePartial, + validatorLocation, + validatorType.Name + ) + ); + } + + if (validatorType.ContainingType is not null) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.UnsupportedValidatorShape, + validatorLocation, + validatorType.Name, + "nested validators are not supported" + ) + ); + } + + if (validatorType.TypeParameters.Length != 0) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.UnsupportedValidatorShape, + validatorLocation, + validatorType.Name, + "generic validators are not supported" + ) + ); + } + + var baseType = validatorType.BaseType; + if (baseType is null) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.UnsupportedValidatorShape, + validatorLocation, + validatorType.Name, + "v1 requires directly inheriting from Validator or Validator" + ) + ); + return; + } + + var baseMetadataName = GetMetadataName(baseType.OriginalDefinition); + if (baseMetadataName == KnownTypeNames.AsyncValidator || + baseMetadataName == KnownTypeNames.TransformingAsyncValidator) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.AsyncValidatorUnsupported, + validatorLocation, + validatorType.Name + ) + ); + return; + } + + if (baseMetadataName != KnownTypeNames.Validator && + baseMetadataName != KnownTypeNames.TransformingValidator) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.UnsupportedValidatorShape, + validatorLocation, + validatorType.Name, + "custom or indirect validator base classes are not supported; v1 requires directly inheriting from Validator or Validator" + ) + ); + } + } + + private static void AnalyzePerformValidationBody( + SemanticModel semanticModel, + MethodDeclarationSyntax methodDeclaration, + string? sourceParameterName, + ICollection rules, + ICollection diagnostics, + CancellationToken cancellationToken + ) + { + if (methodDeclaration.Body is null) + { + return; + } + + foreach (var statement in methodDeclaration.Body.Statements) + { + cancellationToken.ThrowIfCancellationRequested(); + if (TryGetTopLevelCheckExpression(statement, out var expression)) + { + AnalyzeCheckExpression(semanticModel, expression, sourceParameterName, rules, diagnostics, cancellationToken); + continue; + } + + WarnForNestedChecks(semanticModel, statement, diagnostics, cancellationToken); + } + } + + private static void AnalyzeCheckExpression( + SemanticModel semanticModel, + ExpressionSyntax expression, + string? sourceParameterName, + ICollection rules, + ICollection diagnostics, + CancellationToken cancellationToken + ) + { + var invocations = CollectInvocationChain(expression); + if (invocations.Count == 0 || !IsValidationContextCheck(semanticModel, invocations[0], cancellationToken)) + { + return; + } + + var target = TryInferTarget(semanticModel, invocations[0], sourceParameterName, cancellationToken); + for (var i = 1; i < invocations.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var invocation = invocations[i]; + var symbol = semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol; + if (symbol is null) + { + continue; + } + + if (UsesErrorOverrides(symbol)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.OpaqueValidationFlow, + invocation.GetLocation(), + symbol.Name + ) + ); + continue; + } + + var ruleAttribute = GetAttribute(symbol.ReducedFrom ?? symbol, KnownTypeNames.ValidationRuleAttribute); + if (ruleAttribute is null) + { + if (symbol.Name == "Must" || symbol.Name == "Custom") + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.OpaqueValidationFlow, + invocation.GetLocation(), + symbol.Name + ) + ); + } + + continue; + } + + var rule = CreateRuleCall(semanticModel, invocation, symbol, ruleAttribute, target, diagnostics, cancellationToken); + if (rule is not null) + { + rules.Add(rule); + } + } + } + + private static RuleCallModel? CreateRuleCall( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + IMethodSymbol symbol, + AttributeData ruleAttribute, + string? target, + ICollection diagnostics, + CancellationToken cancellationToken + ) + { + var definitionSymbol = symbol.ReducedFrom ?? symbol; + var code = ruleAttribute.ConstructorArguments.Length > 0 ? + ruleAttribute.ConstructorArguments[0].Value as string : + null; + if (string.IsNullOrWhiteSpace(code)) + { + return null; + } + + var shape = RuleMetadataShape.Registered; + if (ruleAttribute.ConstructorArguments.Length > 1 && + ruleAttribute.ConstructorArguments[1].Value is int shapeValue) + { + shape = (RuleMetadataShape) shapeValue; + } + + var metadataValues = ImmutableArray.CreateBuilder(); + foreach (var metadataAttribute in definitionSymbol.GetAttributes() + .Where(static attribute => IsAttribute(attribute, KnownTypeNames.ValidationRuleMetadataAttribute))) + { + var metadataKey = metadataAttribute.ConstructorArguments.Length > 0 ? + metadataAttribute.ConstructorArguments[0].Value as string : + null; + var sourceArgument = metadataAttribute.ConstructorArguments.Length > 1 ? + metadataAttribute.ConstructorArguments[1].Value as string : + null; + if (string.IsNullOrWhiteSpace(metadataKey)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(sourceArgument)) + { + if (!TryResolveArgumentConstant( + semanticModel, + invocation, + symbol, + sourceArgument!, + cancellationToken, + out var value, + out var valueTypeName, + out var hasConstantValue + )) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidRuleMetadata, + invocation.GetLocation(), + symbol.Name, + sourceArgument + ) + ); + return null; + } + + metadataValues.Add(new MetadataValueModel(metadataKey!, value, hasConstantValue, valueTypeName)); + continue; + } + + if (TryGetConstantMetadataValue(metadataAttribute, out var constantValue, out var constantTypeName)) + { + metadataValues.Add(new MetadataValueModel(metadataKey!, constantValue, true, constantTypeName)); + } + } + + if (!TryGetMetadataSchemaProperties( + ruleAttribute, + code!, + symbol.Name, + invocation.GetLocation(), + diagnostics, + out var metadataSchemaProperties + )) + { + return null; + } + + var typedValueTypeName = ResolveTypedValueTypeName(symbol, shape); + return new RuleCallModel( + code!, + shape, + target, + typedValueTypeName, + metadataValues.ToImmutable(), + metadataSchemaProperties + ); + } + + private static bool TryGetMetadataSchemaProperties( + AttributeData ruleAttribute, + string code, + string ruleName, + Location location, + ICollection diagnostics, + out ImmutableArray metadataSchemaProperties + ) + { + var builder = ImmutableArray.CreateBuilder(); + metadataSchemaProperties = builder.ToImmutable(); + + var errorDefinitionType = ruleAttribute.NamedArguments.FirstOrDefault(static argument => + argument.Key == "ErrorDefinitionType" + ).Value.Value as INamedTypeSymbol; + if (errorDefinitionType is null) + { + return true; + } + + var contractAttribute = GetAttribute(errorDefinitionType, KnownTypeNames.ValidationErrorContractAttribute); + var contractCode = contractAttribute?.ConstructorArguments.Length > 0 ? + contractAttribute.ConstructorArguments[0].Value as string : + null; + if (contractCode != code) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidErrorContract, + location, + ruleName, + errorDefinitionType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + code + ) + ); + return false; + } + + foreach (var metadataAttribute in errorDefinitionType.GetAttributes() + .Where(static attribute => IsAttribute( + attribute, + KnownTypeNames.ValidationErrorMetadataContractAttribute + ))) + { + if (metadataAttribute.ConstructorArguments.Length < 2 || + metadataAttribute.ConstructorArguments[0].Value is not string metadataKey || + metadataAttribute.ConstructorArguments[1].Value is not ITypeSymbol metadataType) + { + continue; + } + + builder.Add( + new MetadataSchemaPropertyModel( + metadataKey, + metadataType.ToDisplayString(FullyQualifiedTypeFormat) + ) + ); + } + + metadataSchemaProperties = builder + .OrderBy(static property => property.Key, StringComparer.Ordinal) + .ToImmutableArray(); + return true; + } + + private static bool TryGetConstantMetadataValue( + AttributeData metadataAttribute, + out object? value, + out string typeName + ) + { + value = null; + typeName = "object"; + string? stringValue = null; + long int64Value = 0; + var hasInt64Value = false; + var booleanValue = false; + var hasBooleanValue = false; + ITypeSymbol? typeValue = null; + + foreach (var namedArgument in metadataAttribute.NamedArguments) + { + switch (namedArgument.Key) + { + case "ConstantStringValue": + stringValue = namedArgument.Value.Value as string; + break; + case "ConstantInt64Value" when namedArgument.Value.Value is long longValue: + int64Value = longValue; + break; + case "HasConstantInt64Value" when namedArgument.Value.Value is bool boolValue: + hasInt64Value = boolValue; + break; + case "ConstantBooleanValue" when namedArgument.Value.Value is bool boolValue: + booleanValue = boolValue; + break; + case "HasConstantBooleanValue" when namedArgument.Value.Value is bool boolValue: + hasBooleanValue = boolValue; + break; + case "ConstantTypeValue" when namedArgument.Value.Value is ITypeSymbol symbolValue: + typeValue = symbolValue; + break; + } + } + + if (stringValue is not null) + { + value = stringValue; + typeName = "global::System.String"; + return true; + } + + if (hasInt64Value) + { + value = int64Value; + typeName = "global::System.Int64"; + return true; + } + + if (hasBooleanValue) + { + value = booleanValue; + typeName = "global::System.Boolean"; + return true; + } + + if (typeValue is not null) + { + value = typeValue.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + typeName = "global::System.String"; + return true; + } + + return false; + } + + private static bool TryResolveArgumentConstant( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + IMethodSymbol symbol, + string sourceArgument, + CancellationToken cancellationToken, + out object? value, + out string typeName, + out bool hasConstantValue + ) + { + value = null; + typeName = "object"; + hasConstantValue = false; + + var parameterIndex = -1; + for (var i = 0; i < symbol.Parameters.Length; i++) + { + if (symbol.Parameters[i].Name == sourceArgument) + { + parameterIndex = i; + typeName = symbol.Parameters[i].Type.ToDisplayString(FullyQualifiedTypeFormat); + break; + } + } + + if (parameterIndex < 0) + { + return false; + } + + foreach (var argument in invocation.ArgumentList.Arguments) + { + if (argument.NameColon is not null) + { + if (argument.NameColon.Name.Identifier.ValueText != sourceArgument) + { + continue; + } + } + else if (invocation.ArgumentList.Arguments.IndexOf(argument) != parameterIndex) + { + continue; + } + + var constant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + if (constant.HasValue) + { + value = constant.Value; + hasConstantValue = true; + } + + return true; + } + + var parameter = symbol.Parameters[parameterIndex]; + if (parameter.HasExplicitDefaultValue) + { + value = parameter.ExplicitDefaultValue; + hasConstantValue = true; + } + + return true; + } + + private static void WarnForNestedChecks( + SemanticModel semanticModel, + StatementSyntax statement, + ICollection diagnostics, + CancellationToken cancellationToken + ) + { + foreach (var invocation in statement.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (IsValidationContextCheck(semanticModel, invocation, cancellationToken)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.NestedCheckSkipped, + invocation.GetLocation(), + invocation.Expression.ToString() + ) + ); + } + } + } + + private static bool TryGetTopLevelCheckExpression(StatementSyntax statement, out ExpressionSyntax expression) + { + expression = null!; + switch (statement) + { + case ExpressionStatementSyntax expressionStatement: + expression = UnwrapAssignment(expressionStatement.Expression); + return true; + case LocalDeclarationStatementSyntax localDeclaration when localDeclaration.Declaration.Variables.Count == 1: + var initializer = localDeclaration.Declaration.Variables[0].Initializer; + if (initializer is null) + { + return false; + } + + expression = initializer.Value; + return true; + default: + return false; + } + } + + private static ExpressionSyntax UnwrapAssignment(ExpressionSyntax expression) + { + return expression is AssignmentExpressionSyntax assignment ? + assignment.Right : + expression; + } + + private static List CollectInvocationChain(ExpressionSyntax expression) + { + var invocations = new List(); + CollectInvocationChain(expression, invocations); + return invocations; + } + + private static void CollectInvocationChain(ExpressionSyntax expression, ICollection invocations) + { + if (expression is not InvocationExpressionSyntax invocation) + { + return; + } + + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + CollectInvocationChain(memberAccess.Expression, invocations); + } + + invocations.Add(invocation); + } + + private static bool IsValidationContextCheck( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + CancellationToken cancellationToken + ) + { + var symbol = semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol; + return symbol is not null && + symbol.Name == "Check" && + GetMetadataName(symbol.ContainingType) == KnownTypeNames.ValidationContext; + } + + private static string? TryInferTarget( + SemanticModel semanticModel, + InvocationExpressionSyntax checkInvocation, + string? sourceParameterName, + CancellationToken cancellationToken + ) + { + if (sourceParameterName is null || checkInvocation.ArgumentList.Arguments.Count == 0) + { + return null; + } + + var valueExpression = checkInvocation.ArgumentList.Arguments[0].Expression; + if (valueExpression is not MemberAccessExpressionSyntax memberAccess || + memberAccess.Expression is not IdentifierNameSyntax identifier || + identifier.Identifier.ValueText != sourceParameterName) + { + return null; + } + + var symbol = semanticModel.GetSymbolInfo(memberAccess, cancellationToken).Symbol; + if (symbol is IPropertySymbol propertySymbol) + { + var jsonName = GetJsonPropertyName(propertySymbol); + if (!string.IsNullOrWhiteSpace(jsonName)) + { + return jsonName; + } + } + + return ToCamelCase(memberAccess.Name.Identifier.ValueText); + } + + private static string? GetJsonPropertyName(IPropertySymbol propertySymbol) + { + foreach (var attribute in propertySymbol.GetAttributes()) + { + if (IsAttribute(attribute, KnownTypeNames.JsonPropertyNameAttribute) && + attribute.ConstructorArguments.Length == 1) + { + return attribute.ConstructorArguments[0].Value as string; + } + } + + return null; + } + + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) + { + return name; + } + + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + + private static string? ResolveTypedValueTypeName(IMethodSymbol symbol, RuleMetadataShape shape) + { + if (shape == RuleMetadataShape.Registered) + { + return null; + } + + if (symbol.TypeArguments.Length > 0) + { + return symbol.TypeArguments[0].ToDisplayString(FullyQualifiedTypeFormat); + } + + return null; + } + + private static bool UsesErrorOverrides(IMethodSymbol symbol) + { + return symbol.Parameters.Any(static parameter => GetMetadataName(parameter.Type) == KnownTypeNames.ErrorOverrides); + } + + private static AttributeData? GetAttribute(ISymbol symbol, string metadataName) + { + return symbol.GetAttributes().FirstOrDefault(attribute => IsAttribute(attribute, metadataName)); + } + + private static bool IsAttribute(AttributeData attribute, string metadataName) + { + return attribute.AttributeClass is not null && GetMetadataName(attribute.AttributeClass) == metadataName; + } + + private static IEnumerable GetErrorHints( + INamedTypeSymbol validatorType, + IMethodSymbol performValidation + ) + { + foreach (var hint in GetErrorHints(validatorType.GetAttributes())) + { + yield return hint; + } + + foreach (var hint in GetErrorHints(performValidation.GetAttributes())) + { + yield return hint; + } + } + + private static IEnumerable GetErrorHints(IEnumerable attributes) + { + foreach (var attribute in attributes) + { + if (!IsAttribute(attribute, KnownTypeNames.ErrorHintAttribute) || + attribute.ConstructorArguments.Length == 0 || + attribute.ConstructorArguments[0].Value is not string code) + { + continue; + } + + var metadataTypeName = attribute.ConstructorArguments.Length > 1 && + attribute.ConstructorArguments[1].Value is ITypeSymbol metadataType ? + metadataType.ToDisplayString(FullyQualifiedTypeFormat) : + null; + yield return new ErrorHintModel(code, metadataTypeName); + } + } + + private static bool GetAllowUnknownErrorCodes(INamedTypeSymbol validatorType) + { + var attribute = GetAttribute(validatorType, KnownTypeNames.GenerateAttribute); + if (attribute is null) + { + return false; + } + + foreach (var namedArgument in attribute.NamedArguments) + { + if (namedArgument.Key == "AllowUnknownErrorCodes" && + namedArgument.Value.Value is bool allowUnknownErrorCodes) + { + return allowUnknownErrorCodes; + } + } + + return false; + } + + private static bool TryGetPerformValidationMethod( + INamedTypeSymbol validatorType, + CancellationToken cancellationToken, + out IMethodSymbol? method, + out MethodDeclarationSyntax? declaration + ) + { + foreach (var candidate in validatorType.GetMembers("PerformValidation").OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (candidate.Parameters.Length != 3 || + candidate.DeclaredAccessibility != Accessibility.Protected || + candidate.DeclaringSyntaxReferences.Length == 0) + { + continue; + } + + var syntax = candidate.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken); + if (syntax is MethodDeclarationSyntax methodDeclaration) + { + method = candidate; + declaration = methodDeclaration; + return true; + } + } + + method = null; + declaration = null; + return false; + } + + private static bool TryGetPrimaryClassDeclaration( + INamedTypeSymbol validatorType, + CancellationToken cancellationToken, + out ClassDeclarationSyntax declaration + ) + { + foreach (var syntaxReference in validatorType.DeclaringSyntaxReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + if (syntaxReference.GetSyntax(cancellationToken) is ClassDeclarationSyntax classDeclaration) + { + declaration = classDeclaration; + return true; + } + } + + declaration = null!; + return false; + } + + private static string GetAccessibility(Accessibility accessibility) + { + switch (accessibility) + { + case Accessibility.Public: + return "public"; + case Accessibility.Internal: + return "internal"; + default: + return "public"; + } + } + + private static string CreateHintName(INamedTypeSymbol validatorType) + { + var metadataName = validatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", string.Empty); + var chars = metadataName.Select(static c => char.IsLetterOrDigit(c) || c == '.' ? c : '_').ToArray(); + return new string(chars) + ".PortableValidationOpenApi.g.cs"; + } + + private static string GetMetadataName(ITypeSymbol typeSymbol) + { + var containingNamespace = typeSymbol.ContainingNamespace?.IsGlobalNamespace == false ? + typeSymbol.ContainingNamespace.ToDisplayString() + "." : + string.Empty; + return containingNamespace + typeSymbol.MetadataName; + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs new file mode 100644 index 0000000..65531ec --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +internal static class ValidatorOpenApiEmitter +{ + public static string Emit(ValidatorModel model) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using System;"); + builder.AppendLine("using System.Collections.Generic;"); + builder.AppendLine("using Light.PortableResults.AspNetCore.OpenApi;"); + builder.AppendLine("using Light.PortableResults.Validation.OpenApi;"); + builder.AppendLine("using Microsoft.OpenApi;"); + builder.AppendLine(); + + if (model.NamespaceName is not null) + { + builder.Append("namespace ").Append(model.NamespaceName).AppendLine(); + builder.AppendLine("{"); + } + + var indent = model.NamespaceName is null ? "" : " "; + builder.Append(indent) + .Append(model.Accessibility) + .Append(" partial class ") + .Append(model.ClassName) + .Append(" : IPortableValidationOpenApiContract") + .AppendLine(); + builder.Append(indent).AppendLine("{"); + builder.Append(indent).AppendLine(" public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)"); + builder.Append(indent).AppendLine(" {"); + builder.Append(indent).AppendLine(" if (builder is null)"); + builder.Append(indent).AppendLine(" {"); + builder.Append(indent).AppendLine(" throw new ArgumentNullException(nameof(builder));"); + builder.Append(indent).AppendLine(" }"); + builder.AppendLine(); + + EmitSchemaConfiguration(builder, model, indent + " "); + EmitExamples(builder, model, indent + " "); + + if (model.AllowUnknownErrorCodes) + { + builder.Append(indent).AppendLine(" builder.AllowUnknownErrorCodes();"); + } + + builder.Append(indent).AppendLine(" }"); + builder.Append(indent).AppendLine("}"); + + if (model.NamespaceName is not null) + { + builder.AppendLine("}"); + } + + return builder.ToString(); + } + + private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorModel model, string indent) + { + var registeredCodes = model.Rules + .Where(static rule => rule.Shape == RuleMetadataShape.Registered && + rule.MetadataSchemaProperties.Length == 0) + .Select(static rule => rule.Code) + .Concat(model.Hints.Where(static hint => hint.MetadataTypeName is null).Select(static hint => hint.Code)) + .Distinct(StringComparer.Ordinal) + .OrderBy(static code => code, StringComparer.Ordinal) + .ToArray(); + if (registeredCodes.Length > 0) + { + builder.Append(indent).Append("builder.WithErrorCodes("); + for (var i = 0; i < registeredCodes.Length; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(ToStringLiteral(registeredCodes[i])); + } + + builder.AppendLine(");"); + } + + foreach (var hint in model.Hints.Where(static hint => hint.MetadataTypeName is not null)) + { + builder.Append(indent) + .Append("builder.WithErrorMetadata<") + .Append(hint.MetadataTypeName) + .Append(">(") + .Append(ToStringLiteral(hint.Code)) + .AppendLine(");"); + } + + foreach (var rule in model.Rules + .Where(static rule => rule.Shape == RuleMetadataShape.Registered && + rule.MetadataSchemaProperties.Length > 0) + .Distinct(InlineSchemaRuleComparer.Instance) + .OrderBy(static rule => rule.Code, StringComparer.Ordinal)) + { + EmitInlineSchemaConfiguration(builder, rule, indent); + } + + foreach (var rule in model.Rules + .Where(static rule => rule.Shape != RuleMetadataShape.Registered) + .Distinct(RuleSchemaKeyComparer.Instance) + .OrderBy(static rule => rule.Code, StringComparer.Ordinal) + .ThenBy(static rule => rule.TypedValueTypeName, StringComparer.Ordinal)) + { + var helperName = GetTypedHelperName(rule); + if (helperName is null || rule.TypedValueTypeName is null) + { + continue; + } + + builder.Append(indent) + .Append("builder.") + .Append(helperName) + .Append('<') + .Append(rule.TypedValueTypeName) + .AppendLine(">();"); + } + + if (registeredCodes.Length > 0 || + model.Hints.Length > 0 || + model.Rules.Any(static rule => rule.Shape != RuleMetadataShape.Registered) || + model.Rules.Any(static rule => rule.Shape == RuleMetadataShape.Registered && + rule.MetadataSchemaProperties.Length > 0)) + { + builder.AppendLine(); + } + } + + private static void EmitInlineSchemaConfiguration(StringBuilder builder, RuleCallModel rule, string indent) + { + builder.Append(indent) + .Append("builder.WithErrorMetadata(") + .Append(ToStringLiteral(rule.Code)) + .AppendLine(", _ => new OpenApiSchema"); + builder.Append(indent).AppendLine("{"); + builder.Append(indent).AppendLine(" Type = JsonSchemaType.Object,"); + builder.Append(indent).AppendLine(" Properties = new Dictionary(StringComparer.Ordinal)"); + builder.Append(indent).AppendLine(" {"); + + foreach (var property in rule.MetadataSchemaProperties.OrderBy(static property => property.Key, StringComparer.Ordinal)) + { + builder.Append(indent) + .Append(" [") + .Append(ToStringLiteral(property.Key)) + .Append("] = PortableOpenApiSchemaTypeMapper.Map<") + .Append(property.TypeName) + .AppendLine(">(),"); + } + + builder.Append(indent).AppendLine(" },"); + builder.Append(indent).Append(" Required = new HashSet(StringComparer.Ordinal) { "); + for (var i = 0; i < rule.MetadataSchemaProperties.Length; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(ToStringLiteral(rule.MetadataSchemaProperties[i].Key)); + } + + builder.AppendLine(" }"); + builder.Append(indent) + .Append("}, ") + .Append(ToStringLiteral(rule.Code + "Metadata")) + .AppendLine(");"); + } + + private static void EmitExamples(StringBuilder builder, ValidatorModel model, string indent) + { + foreach (var rule in model.Rules) + { + if (rule.MetadataValues.Any(static metadata => !metadata.HasConstantValue)) + { + continue; + } + + builder.Append(indent) + .Append("builder.WithErrorExample(") + .Append(ToStringLiteral(rule.Code)) + .Append(", ") + .Append(rule.Target is null ? "null" : ToStringLiteral(rule.Target)); + + if (rule.MetadataValues.Length > 0) + { + builder.Append(", "); + EmitMetadataDictionary(builder, rule.MetadataValues); + } + + builder.AppendLine(");"); + } + + if (model.Rules.Length > 0) + { + builder.AppendLine(); + } + } + + private static void EmitMetadataDictionary(StringBuilder builder, IEnumerable metadataValues) + { + builder.Append("new Dictionary(StringComparer.Ordinal) { "); + var isFirst = true; + foreach (var metadataValue in metadataValues) + { + if (!isFirst) + { + builder.Append(", "); + } + + builder.Append('[') + .Append(ToStringLiteral(metadataValue.Key)) + .Append("] = ") + .Append(ToLiteral(metadataValue.Value)); + isFirst = false; + } + + builder.Append(" }"); + } + + private static string? GetTypedHelperName(RuleCallModel rule) + { + switch (rule.Code) + { + case "EqualTo": + return "WithEqualToError"; + case "NotEqualTo": + return "WithNotEqualToError"; + case "GreaterThan": + return "WithGreaterThanError"; + case "GreaterThanOrEqualTo": + return "WithGreaterThanOrEqualToError"; + case "LessThan": + return "WithLessThanError"; + case "LessThanOrEqualTo": + return "WithLessThanOrEqualToError"; + case "InRange": + return "WithInRangeError"; + case "NotInRange": + return "WithNotInRangeError"; + case "ExclusiveRange": + return "WithExclusiveRangeError"; + default: + return null; + } + } + + private static string ToLiteral(object? value) + { + switch (value) + { + case null: + return "null"; + case string stringValue: + return ToStringLiteral(stringValue); + case char charValue: + return "'" + EscapeChar(charValue) + "'"; + case bool boolValue: + return boolValue ? "true" : "false"; + case byte byteValue: + return byteValue.ToString(CultureInfo.InvariantCulture); + case sbyte sbyteValue: + return sbyteValue.ToString(CultureInfo.InvariantCulture); + case short shortValue: + return shortValue.ToString(CultureInfo.InvariantCulture); + case ushort ushortValue: + return ushortValue.ToString(CultureInfo.InvariantCulture); + case int intValue: + return intValue.ToString(CultureInfo.InvariantCulture); + case uint uintValue: + return uintValue.ToString(CultureInfo.InvariantCulture) + "U"; + case long longValue: + return longValue.ToString(CultureInfo.InvariantCulture) + "L"; + case ulong ulongValue: + return ulongValue.ToString(CultureInfo.InvariantCulture) + "UL"; + case float floatValue: + return floatValue.ToString("R", CultureInfo.InvariantCulture) + "F"; + case double doubleValue: + return doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D"; + case decimal decimalValue: + return decimalValue.ToString(CultureInfo.InvariantCulture) + "M"; + default: + return ToStringLiteral(value.ToString() ?? string.Empty); + } + } + + private static string ToStringLiteral(string value) + { + var builder = new StringBuilder(value.Length + 2); + builder.Append('"'); + foreach (var c in value) + { + builder.Append(EscapeChar(c)); + } + + builder.Append('"'); + return builder.ToString(); + } + + private static string EscapeChar(char value) + { + switch (value) + { + case '\\': + return "\\\\"; + case '"': + return "\\\""; + case '\'': + return "\\'"; + case '\0': + return "\\0"; + case '\a': + return "\\a"; + case '\b': + return "\\b"; + case '\f': + return "\\f"; + case '\n': + return "\\n"; + case '\r': + return "\\r"; + case '\t': + return "\\t"; + case '\v': + return "\\v"; + default: + return char.IsControl(value) ? + "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : + value.ToString(); + } + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs new file mode 100644 index 0000000..6850fab --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Represents the output of validator OpenAPI source-generation analysis. +/// +public sealed class ValidatorOpenApiAnalysis : IEquatable +{ + /// + /// Initializes a new instance of . + /// + public ValidatorOpenApiAnalysis( + string hintName, + string? source, + ImmutableArray diagnostics + ) + { + HintName = hintName; + Source = source; + Diagnostics = diagnostics; + } + + /// + /// Gets the generated source hint name. + /// + public string HintName { get; } + + /// + /// Gets the generated source, or when no source should be emitted. + /// + public string? Source { get; } + + /// + /// Gets diagnostics produced during analysis. + /// + public ImmutableArray Diagnostics { get; } + + /// + public bool Equals(ValidatorOpenApiAnalysis? other) + { + if (other is null) + { + return false; + } + + return HintName == other.HintName && + Source == other.Source && + DiagnosticsEqual(Diagnostics, other.Diagnostics); + } + + /// + public override bool Equals(object? obj) => Equals(obj as ValidatorOpenApiAnalysis); + + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = HintName.GetHashCode(); + hashCode = hashCode * 31 + (Source?.GetHashCode() ?? 0); + foreach (var diagnostic in Diagnostics) + { + hashCode = hashCode * 31 + diagnostic.Id.GetHashCode(); + hashCode = hashCode * 31 + diagnostic.Severity.GetHashCode(); + hashCode = hashCode * 31 + diagnostic.GetMessage().GetHashCode(); + } + + return hashCode; + } + } + + private static bool DiagnosticsEqual( + ImmutableArray left, + ImmutableArray right + ) + { + if (left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (left[i].Id != right[i].Id || + left[i].Severity != right[i].Severity || + left[i].GetMessage() != right[i].GetMessage()) + { + return false; + } + } + + return true; + } +} + +internal sealed class ValidatorModel +{ + public ValidatorModel( + string metadataName, + string displayName, + string? namespaceName, + string accessibility, + string className, + bool allowUnknownErrorCodes, + ImmutableArray rules, + ImmutableArray hints + ) + { + MetadataName = metadataName; + DisplayName = displayName; + NamespaceName = namespaceName; + Accessibility = accessibility; + ClassName = className; + AllowUnknownErrorCodes = allowUnknownErrorCodes; + Rules = rules; + Hints = hints; + } + + public string MetadataName { get; } + public string DisplayName { get; } + public string? NamespaceName { get; } + public string Accessibility { get; } + public string ClassName { get; } + public bool AllowUnknownErrorCodes { get; } + public ImmutableArray Rules { get; } + public ImmutableArray Hints { get; } +} + +internal sealed class RuleCallModel +{ + public RuleCallModel( + string code, + RuleMetadataShape shape, + string? target, + string? typedValueTypeName, + ImmutableArray metadataValues, + ImmutableArray metadataSchemaProperties + ) + { + Code = code; + Shape = shape; + Target = target; + TypedValueTypeName = typedValueTypeName; + MetadataValues = metadataValues; + MetadataSchemaProperties = metadataSchemaProperties; + } + + public string Code { get; } + public RuleMetadataShape Shape { get; } + public string? Target { get; } + public string? TypedValueTypeName { get; } + public ImmutableArray MetadataValues { get; } + public ImmutableArray MetadataSchemaProperties { get; } +} + +internal sealed class MetadataValueModel +{ + public MetadataValueModel(string key, object? value, bool hasConstantValue, string typeName) + { + Key = key; + Value = value; + HasConstantValue = hasConstantValue; + TypeName = typeName; + } + + public string Key { get; } + public object? Value { get; } + public bool HasConstantValue { get; } + public string TypeName { get; } +} + +internal sealed class MetadataSchemaPropertyModel +{ + public MetadataSchemaPropertyModel(string key, string typeName) + { + Key = key; + TypeName = typeName; + } + + public string Key { get; } + public string TypeName { get; } +} + +internal sealed class ErrorHintModel +{ + public ErrorHintModel(string code, string? metadataTypeName) + { + Code = code; + MetadataTypeName = metadataTypeName; + } + + public string Code { get; } + public string? MetadataTypeName { get; } +} + +internal enum RuleMetadataShape +{ + Registered = 0, + TypedComparison = 1, + TypedRange = 2 +} + +internal sealed class RuleSchemaKeyComparer : IEqualityComparer +{ + public static RuleSchemaKeyComparer Instance { get; } = new (); + + public bool Equals(RuleCallModel? x, RuleCallModel? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x.Code == y.Code && + x.Shape == y.Shape && + x.TypedValueTypeName == y.TypedValueTypeName; + } + + public int GetHashCode(RuleCallModel obj) + { + unchecked + { + var hashCode = 17; + hashCode = hashCode * 31 + obj.Code.GetHashCode(); + hashCode = hashCode * 31 + obj.Shape.GetHashCode(); + hashCode = hashCode * 31 + (obj.TypedValueTypeName?.GetHashCode() ?? 0); + return hashCode; + } + } +} + +internal sealed class InlineSchemaRuleComparer : IEqualityComparer +{ + public static InlineSchemaRuleComparer Instance { get; } = new (); + + public bool Equals(RuleCallModel? x, RuleCallModel? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null || + x.Code != y.Code || + x.MetadataSchemaProperties.Length != y.MetadataSchemaProperties.Length) + { + return false; + } + + for (var i = 0; i < x.MetadataSchemaProperties.Length; i++) + { + if (x.MetadataSchemaProperties[i].Key != y.MetadataSchemaProperties[i].Key || + x.MetadataSchemaProperties[i].TypeName != y.MetadataSchemaProperties[i].TypeName) + { + return false; + } + } + + return true; + } + + public int GetHashCode(RuleCallModel obj) + { + unchecked + { + var hashCode = obj.Code.GetHashCode(); + foreach (var property in obj.MetadataSchemaProperties) + { + hashCode = hashCode * 31 + property.Key.GetHashCode(); + hashCode = hashCode * 31 + property.TypeName.GetHashCode(); + } + + return hashCode; + } + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/packages.lock.json b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/packages.lock.json new file mode 100644 index 0000000..8e29567 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/packages.lock.json @@ -0,0 +1,155 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[3.11.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Buffers": "4.5.1", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5", + "System.Numerics.Vectors": "4.5.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "7.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "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" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "DMYBnrFZvLnBKn14VavEuuIr31CY6YY2i2L9P8DorS/Qp6ifRR8ZPLdJCFLFfjikNq8DykbYyLd/RP6lSqHcWw==", + "dependencies": { + "System.IO.Hashing": "10.0.5" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Buffers": "4.5.1", + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5", + "System.Numerics.Vectors": "4.5.0", + "System.Reflection.Metadata": "9.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "7.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.201", + "contentHash": "QbBYhkjgL6rCnBfDbzsAJLlsad13TlBHqYCFDIw56OO2g6ix+9RsmY8uxiQGdWwFKbZXaXyAA6jDCzFYVGCZDw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0", + "System.Memory": "4.5.5" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "LSyCblMpvOe0N3E+8e0skHcrIhgV2huaNcjUUEa8hRtgEAm36aGkRoC8Jxlb6Ra6GSfF29ftduPNywin8XolzQ==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Collections.Immutable": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "nozrOKfEgfrvvswkCfxaumY68RA/x1F3ZYwtwRvva8ul91NCnUzb6MKpl5/P7u9v/nIagVL4OXXj8d007tucxw==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs index 606047a..0a9f4ab 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs @@ -12,172 +12,280 @@ namespace Light.PortableResults.Validation.OpenApi; public static class BuiltInValidationErrorBuilderExtensions { /// Documents endpoint-specific EqualTo validation error metadata. - public static PortableProblemOpenApiBuilder WithEqualToError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata( + public static PortableProblemOpenApiBuilder WithEqualToError( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.EqualTo, _ => CreateComparisonSchema(), $"EqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.EqualTo, target, comparativeValue); + } /// Documents endpoint-specific EqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithEqualToError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.EqualTo, _ => CreateComparisonSchema(), $"EqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.EqualTo, target, comparativeValue); + } /// Documents endpoint-specific NotEqualTo validation error metadata. - public static PortableProblemOpenApiBuilder WithNotEqualToError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata( + public static PortableProblemOpenApiBuilder WithNotEqualToError( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.NotEqualTo, _ => CreateComparisonSchema(), $"NotEqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.NotEqualTo, target, comparativeValue); + } /// Documents endpoint-specific NotEqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithNotEqualToError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.NotEqualTo, _ => CreateComparisonSchema(), $"NotEqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.NotEqualTo, target, comparativeValue); + } /// Documents endpoint-specific GreaterThan validation error metadata. - public static PortableProblemOpenApiBuilder WithGreaterThanError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata( + public static PortableProblemOpenApiBuilder WithGreaterThanError( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.GreaterThan, _ => CreateComparisonSchema(), $"GreaterThanMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.GreaterThan, target, comparativeValue); + } /// Documents endpoint-specific GreaterThan validation error metadata. public static PortableValidationProblemOpenApiBuilder WithGreaterThanError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.GreaterThan, _ => CreateComparisonSchema(), $"GreaterThanMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.GreaterThan, target, comparativeValue); + } /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. public static PortableProblemOpenApiBuilder WithGreaterThanOrEqualToError( - this PortableProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.GreaterThanOrEqualTo, _ => CreateComparisonSchema(), $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.GreaterThanOrEqualTo, target, comparativeValue); + } /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithGreaterThanOrEqualToError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.GreaterThanOrEqualTo, _ => CreateComparisonSchema(), $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.GreaterThanOrEqualTo, target, comparativeValue); + } /// Documents endpoint-specific LessThan validation error metadata. - public static PortableProblemOpenApiBuilder WithLessThanError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata( + public static PortableProblemOpenApiBuilder WithLessThanError( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.LessThan, _ => CreateComparisonSchema(), $"LessThanMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.LessThan, target, comparativeValue); + } /// Documents endpoint-specific LessThan validation error metadata. public static PortableValidationProblemOpenApiBuilder WithLessThanError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.LessThan, _ => CreateComparisonSchema(), $"LessThanMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.LessThan, target, comparativeValue); + } /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. public static PortableProblemOpenApiBuilder WithLessThanOrEqualToError( - this PortableProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.LessThanOrEqualTo, _ => CreateComparisonSchema(), $"LessThanOrEqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.LessThanOrEqualTo, target, comparativeValue); + } /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. public static PortableValidationProblemOpenApiBuilder WithLessThanOrEqualToError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? comparativeValue = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.LessThanOrEqualTo, _ => CreateComparisonSchema(), $"LessThanOrEqualToMetadata<{typeof(T).Name}>" ); + return AddComparisonExample(configuredBuilder, ValidationErrorCodes.LessThanOrEqualTo, target, comparativeValue); + } /// Documents endpoint-specific InRange validation error metadata. - public static PortableProblemOpenApiBuilder WithInRangeError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata( + public static PortableProblemOpenApiBuilder WithInRangeError( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? lowerBoundary = default, + T? upperBoundary = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.InRange, _ => CreateRangeSchema(), $"InRangeMetadata<{typeof(T).Name}>" ); + return AddRangeExample(configuredBuilder, ValidationErrorCodes.InRange, target, lowerBoundary, upperBoundary); + } /// Documents endpoint-specific InRange validation error metadata. public static PortableValidationProblemOpenApiBuilder WithInRangeError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? lowerBoundary = default, + T? upperBoundary = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.InRange, _ => CreateRangeSchema(), $"InRangeMetadata<{typeof(T).Name}>" ); + return AddRangeExample(configuredBuilder, ValidationErrorCodes.InRange, target, lowerBoundary, upperBoundary); + } /// Documents endpoint-specific NotInRange validation error metadata. - public static PortableProblemOpenApiBuilder WithNotInRangeError(this PortableProblemOpenApiBuilder builder) => - EnsureBuilder(builder).WithErrorMetadata( + public static PortableProblemOpenApiBuilder WithNotInRangeError( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? lowerBoundary = default, + T? upperBoundary = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.NotInRange, _ => CreateRangeSchema(), $"NotInRangeMetadata<{typeof(T).Name}>" ); + return AddRangeExample(configuredBuilder, ValidationErrorCodes.NotInRange, target, lowerBoundary, upperBoundary); + } /// Documents endpoint-specific NotInRange validation error metadata. public static PortableValidationProblemOpenApiBuilder WithNotInRangeError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? lowerBoundary = default, + T? upperBoundary = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.NotInRange, _ => CreateRangeSchema(), $"NotInRangeMetadata<{typeof(T).Name}>" ); + return AddRangeExample(configuredBuilder, ValidationErrorCodes.NotInRange, target, lowerBoundary, upperBoundary); + } /// Documents endpoint-specific ExclusiveRange validation error metadata. public static PortableProblemOpenApiBuilder WithExclusiveRangeError( - this PortableProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableProblemOpenApiBuilder builder, + string? target = null, + T? lowerBoundary = default, + T? upperBoundary = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.ExclusiveRange, _ => CreateRangeSchema(), $"ExclusiveRangeMetadata<{typeof(T).Name}>" ); + return AddRangeExample(configuredBuilder, ValidationErrorCodes.ExclusiveRange, target, lowerBoundary, upperBoundary); + } /// Documents endpoint-specific ExclusiveRange validation error metadata. public static PortableValidationProblemOpenApiBuilder WithExclusiveRangeError( - this PortableValidationProblemOpenApiBuilder builder - ) => - EnsureBuilder(builder).WithErrorMetadata( + this PortableValidationProblemOpenApiBuilder builder, + string? target = null, + T? lowerBoundary = default, + T? upperBoundary = default + ) + { + var configuredBuilder = EnsureBuilder(builder).WithErrorMetadata( ValidationErrorCodes.ExclusiveRange, _ => CreateRangeSchema(), $"ExclusiveRangeMetadata<{typeof(T).Name}>" ); + return AddRangeExample(configuredBuilder, ValidationErrorCodes.ExclusiveRange, target, lowerBoundary, upperBoundary); + } private static OpenApiSchema CreateComparisonSchema() => new() @@ -206,6 +314,61 @@ private static OpenApiSchema CreateRangeSchema() => } }; + private static PortableProblemOpenApiBuilder AddComparisonExample( + PortableProblemOpenApiBuilder builder, + string code, + string? target, + T? comparativeValue + ) => + target is null ? + builder : + builder.WithErrorExample(code, target, CreateComparisonMetadata(comparativeValue)); + + private static PortableValidationProblemOpenApiBuilder AddComparisonExample( + PortableValidationProblemOpenApiBuilder builder, + string code, + string? target, + T? comparativeValue + ) => + target is null ? + builder : + builder.WithErrorExample(code, target, CreateComparisonMetadata(comparativeValue)); + + private static PortableProblemOpenApiBuilder AddRangeExample( + PortableProblemOpenApiBuilder builder, + string code, + string? target, + T? lowerBoundary, + T? upperBoundary + ) => + target is null ? + builder : + builder.WithErrorExample(code, target, CreateRangeMetadata(lowerBoundary, upperBoundary)); + + private static PortableValidationProblemOpenApiBuilder AddRangeExample( + PortableValidationProblemOpenApiBuilder builder, + string code, + string? target, + T? lowerBoundary, + T? upperBoundary + ) => + target is null ? + builder : + builder.WithErrorExample(code, target, CreateRangeMetadata(lowerBoundary, upperBoundary)); + + private static IReadOnlyDictionary CreateComparisonMetadata(T? comparativeValue) => + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.ComparativeValue] = comparativeValue + }; + + private static IReadOnlyDictionary CreateRangeMetadata(T? lowerBoundary, T? upperBoundary) => + new Dictionary(StringComparer.Ordinal) + { + [ValidationErrorMetadataKeys.LowerBoundary] = lowerBoundary, + [ValidationErrorMetadataKeys.UpperBoundary] = upperBoundary + }; + private static PortableProblemOpenApiBuilder EnsureBuilder(PortableProblemOpenApiBuilder builder) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Light.PortableResults.Validation.OpenApi/GeneratePortableValidationOpenApiAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/GeneratePortableValidationOpenApiAttribute.cs new file mode 100644 index 0000000..81e2762 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/GeneratePortableValidationOpenApiAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Marks a synchronous validator for generated PortableResults validation OpenAPI metadata. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class GeneratePortableValidationOpenApiAttribute : Attribute +{ + /// + /// Gets or sets a value indicating whether the generated endpoint metadata should allow undocumented error codes. + /// + public bool AllowUnknownErrorCodes { get; set; } +} diff --git a/src/Light.PortableResults.Validation.OpenApi/IPortableValidationOpenApiContract.cs b/src/Light.PortableResults.Validation.OpenApi/IPortableValidationOpenApiContract.cs new file mode 100644 index 0000000..07df9c6 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/IPortableValidationOpenApiContract.cs @@ -0,0 +1,14 @@ +using Light.PortableResults.AspNetCore.OpenApi; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Exposes generated validation OpenAPI configuration without reflection. +/// +public interface IPortableValidationOpenApiContract +{ + /// + /// Applies the validator's generated validation OpenAPI metadata to the specified builder. + /// + static abstract void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder); +} diff --git a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj index 6f13556..0254d2f 100644 --- a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj +++ b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj @@ -10,6 +10,7 @@ - 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. + - Opt-in source generator for deriving Minimal API validation OpenAPI metadata from synchronous validators. - Native AOT compatible. @@ -17,6 +18,16 @@ + + diff --git a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorHintAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorHintAttribute.cs new file mode 100644 index 0000000..6177447 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorHintAttribute.cs @@ -0,0 +1,39 @@ +using System; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Explicitly documents an error code emitted by validation logic that the source generator cannot infer. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class PortableValidationOpenApiErrorHintAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiErrorHintAttribute(string code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + Code = code; + } + + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiErrorHintAttribute(string code, Type metadataType) + : this(code) + { + ArgumentNullException.ThrowIfNull(metadataType); + MetadataType = metadataType; + } + + /// + /// Gets the documented error code. + /// + public string Code { get; } + + /// + /// Gets the optional endpoint-inline metadata type for the error code. + /// + public Type? MetadataType { get; } +} diff --git a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiRouteHandlerBuilderExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiRouteHandlerBuilderExtensions.cs new file mode 100644 index 0000000..8ef5d10 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiRouteHandlerBuilderExtensions.cs @@ -0,0 +1,38 @@ +using System; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Minimal API helpers for source-generated validation OpenAPI metadata. +/// +public static class PortableValidationOpenApiRouteHandlerBuilderExtensions +{ + /// + /// Documents a validation problem response by applying the generated OpenAPI contract of + /// . + /// + public static RouteHandlerBuilder ProducesPortableValidationProblemFor( + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json", + Action? configure = null + ) + where TValidator : IPortableValidationOpenApiContract + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.ProducesPortableValidationProblem( + statusCode, + contentType, + openApiBuilder => + { + TValidator.ConfigurePortableValidationOpenApi(openApiBuilder); + configure?.Invoke(openApiBuilder); + } + ); + } +} diff --git a/src/Light.PortableResults.Validation/Checks.Comparable.cs b/src/Light.PortableResults.Validation/Checks.Comparable.cs index 67c7d06..82627c1 100644 --- a/src/Light.PortableResults.Validation/Checks.Comparable.cs +++ b/src/Light.PortableResults.Validation/Checks.Comparable.cs @@ -30,6 +30,8 @@ public static partial class Checks /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.GreaterThan, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsGreaterThan( this Check check, T comparativeValue, @@ -134,6 +136,8 @@ public static Check IsGreaterThan( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.GreaterThanOrEqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsGreaterThanOrEqualTo( this Check check, T comparativeValue, @@ -238,6 +242,8 @@ public static Check IsGreaterThanOrEqualTo( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.LessThan, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsLessThan( this Check check, T comparativeValue, @@ -342,6 +348,8 @@ public static Check IsLessThan( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.LessThanOrEqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsLessThanOrEqualTo( this Check check, T comparativeValue, @@ -449,7 +457,10 @@ public static Check IsLessThanOrEqualTo( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// - public static Check IsInBetween( + [ValidationRule(ValidationErrorCodes.InRange, ValidationRuleMetadataShape.TypedRange)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] + public static Check IsInRange( this Check check, T lowerBoundary, T upperBoundary, @@ -462,14 +473,14 @@ public static Check IsInBetween( return check; } - var value = GetRequiredValue(check.Value, nameof(IsInBetween)); + var value = GetRequiredValue(check.Value, nameof(IsInRange)); var comparer = Comparer.Default; if (comparer.Compare(value, lowerBoundary) >= 0 && comparer.Compare(value, upperBoundary) <= 0) { return check; } - var definition = BuiltInValidationErrorDefinitions.IsInBetween( + var definition = BuiltInValidationErrorDefinitions.IsInRange( check.Context.ErrorDefinitionCache, lowerBoundary, upperBoundary @@ -511,7 +522,7 @@ public static Check IsInBetween( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// - public static Check IsInBetween( + public static Check IsInRange( this Check check, T lowerBoundary, T upperBoundary, @@ -526,14 +537,14 @@ public static Check IsInBetween( return check; } - var value = GetRequiredValue(check.Value, nameof(IsInBetween)); + var value = GetRequiredValue(check.Value, nameof(IsInRange)); var comparer = Comparer.Default; if (comparer.Compare(value, lowerBoundary) >= 0 && comparer.Compare(value, upperBoundary) <= 0) { return check; } - var definition = BuiltInValidationErrorDefinitions.IsInBetween( + var definition = BuiltInValidationErrorDefinitions.IsInRange( check.Context.ErrorDefinitionCache, lowerBoundary, upperBoundary @@ -565,7 +576,10 @@ public static Check IsInBetween( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// - public static Check IsNotInBetween( + [ValidationRule(ValidationErrorCodes.NotInRange, ValidationRuleMetadataShape.TypedRange)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] + public static Check IsNotInRange( this Check check, T lowerBoundary, T upperBoundary, @@ -578,14 +592,14 @@ public static Check IsNotInBetween( return check; } - var value = GetRequiredValue(check.Value, nameof(IsNotInBetween)); + var value = GetRequiredValue(check.Value, nameof(IsNotInRange)); var comparer = Comparer.Default; if (comparer.Compare(value, lowerBoundary) < 0 || comparer.Compare(value, upperBoundary) > 0) { return check; } - var definition = BuiltInValidationErrorDefinitions.IsNotInBetween( + var definition = BuiltInValidationErrorDefinitions.IsNotInRange( check.Context.ErrorDefinitionCache, lowerBoundary, upperBoundary @@ -627,7 +641,7 @@ public static Check IsNotInBetween( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// - public static Check IsNotInBetween( + public static Check IsNotInRange( this Check check, T lowerBoundary, T upperBoundary, @@ -642,14 +656,14 @@ public static Check IsNotInBetween( return check; } - var value = GetRequiredValue(check.Value, nameof(IsNotInBetween)); + var value = GetRequiredValue(check.Value, nameof(IsNotInRange)); var comparer = Comparer.Default; if (comparer.Compare(value, lowerBoundary) < 0 || comparer.Compare(value, upperBoundary) > 0) { return check; } - var definition = BuiltInValidationErrorDefinitions.IsNotInBetween( + var definition = BuiltInValidationErrorDefinitions.IsNotInRange( check.Context.ErrorDefinitionCache, lowerBoundary, upperBoundary @@ -683,6 +697,9 @@ public static Check IsNotInBetween( /// Thrown when the checked value is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.ExclusiveRange, ValidationRuleMetadataShape.TypedRange)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] public static Check IsInExclusiveRange( this Check check, T lowerBoundary, diff --git a/src/Light.PortableResults.Validation/Checks.Count.cs b/src/Light.PortableResults.Validation/Checks.Count.cs index e889526..e79b896 100644 --- a/src/Light.PortableResults.Validation/Checks.Count.cs +++ b/src/Light.PortableResults.Validation/Checks.Count.cs @@ -28,6 +28,8 @@ public static partial class Checks /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.Count)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedCount, nameof(expectedCount))] public static Check HasCount( this Check check, int expectedCount, @@ -120,6 +122,8 @@ public static partial class Checks /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.MinCount)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinCount, nameof(minCount))] public static Check HasMinCount( this Check check, int minCount, @@ -212,6 +216,8 @@ public static partial class Checks /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.MaxCount)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxCount, nameof(maxCount))] public static Check HasMaxCount( this Check check, int maxCount, @@ -304,6 +310,8 @@ public static partial class Checks /// Thrown when the checked collection is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.Count)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedCount, nameof(expectedCount))] public static Check HasCount( this Check check, int expectedCount, @@ -398,6 +406,8 @@ public static Check HasCount( /// Thrown when the checked collection is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.MinCount)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinCount, nameof(minCount))] public static Check HasMinCount( this Check check, int minCount, @@ -492,6 +502,8 @@ public static Check HasMinCount( /// Thrown when the checked collection is . Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.MaxCount)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxCount, nameof(maxCount))] public static Check HasMaxCount( this Check check, int maxCount, @@ -582,6 +594,8 @@ public static Check HasMaxCount( /// /// Thrown when is negative. /// + [ValidationRule(ValidationErrorCodes.Count)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedCount, nameof(expectedCount))] public static Check> HasCount( this Check> check, int expectedCount, @@ -654,6 +668,8 @@ public static Check> HasCount( /// /// Thrown when is negative. /// + [ValidationRule(ValidationErrorCodes.MinCount)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinCount, nameof(minCount))] public static Check> HasMinCount( this Check> check, int minCount, @@ -726,6 +742,8 @@ public static Check> HasMinCount( /// /// Thrown when is negative. /// + [ValidationRule(ValidationErrorCodes.MaxCount)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxCount, nameof(maxCount))] public static Check> HasMaxCount( this Check> check, int maxCount, diff --git a/src/Light.PortableResults.Validation/Checks.Decimals.cs b/src/Light.PortableResults.Validation/Checks.Decimals.cs index f6d5d20..30dbefa 100644 --- a/src/Light.PortableResults.Validation/Checks.Decimals.cs +++ b/src/Light.PortableResults.Validation/Checks.Decimals.cs @@ -33,6 +33,10 @@ public static partial class Checks /// Thrown when is less than 1, is negative, /// or exceeds . /// + [ValidationRule(ValidationErrorCodes.PrecisionScale)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedPrecision, nameof(precision))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedScale, nameof(scale))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.IgnoreTrailingZeros, nameof(ignoreTrailingZeros))] public static Check HasPrecisionAndScale( this Check check, int precision, @@ -162,6 +166,10 @@ public static Check HasPrecisionAndScale( /// Thrown when the checked nullable decimal has no value. Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.PrecisionScale)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedPrecision, nameof(precision))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedScale, nameof(scale))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.IgnoreTrailingZeros, nameof(ignoreTrailingZeros))] public static Check HasPrecisionAndScale( this Check check, int precision, diff --git a/src/Light.PortableResults.Validation/Checks.Empty.cs b/src/Light.PortableResults.Validation/Checks.Empty.cs index 2bde5a2..8676762 100644 --- a/src/Light.PortableResults.Validation/Checks.Empty.cs +++ b/src/Light.PortableResults.Validation/Checks.Empty.cs @@ -24,6 +24,7 @@ public static partial class Checks /// Both and pass without error. If whitespace /// should also be rejected, use instead. /// + [ValidationRule(ValidationErrorCodes.Empty)] public static Check IsEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || string.IsNullOrEmpty(check.Value) ? check : @@ -84,6 +85,7 @@ public static partial class Checks /// strings pass without error; use /// to also reject whitespace. /// + [ValidationRule(ValidationErrorCodes.NotEmpty)] public static Check IsNotEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || !string.IsNullOrEmpty(check.Value) ? check : @@ -139,6 +141,7 @@ public static partial class Checks /// assertions in the chain are skipped; defaults to . /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.Empty)] public static Check IsEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || check.Value == Guid.Empty ? check : @@ -189,6 +192,7 @@ public static Check IsEmpty( /// assertions in the chain are skipped; defaults to . /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.NotEmpty)] public static Check IsNotEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || check.Value != Guid.Empty ? check : @@ -243,6 +247,7 @@ public static Check IsNotEmpty( /// /// A collection is treated as empty and passes without error. /// + [ValidationRule(ValidationErrorCodes.Empty)] public static Check IsEmpty( this Check check, bool shortCircuitOnError = false @@ -328,6 +333,7 @@ public static Check IsEmpty( /// /// A collection triggers a validation error (treated as absent/empty). /// + [ValidationRule(ValidationErrorCodes.NotEmpty)] public static Check IsNotEmpty( this Check check, bool shortCircuitOnError = false @@ -410,6 +416,7 @@ public static Check IsNotEmpty( /// assertions in the chain are skipped; defaults to . /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.Empty)] public static Check> IsEmpty( this Check> check, bool shortCircuitOnError = false @@ -465,6 +472,7 @@ public static Check> IsEmpty( /// assertions in the chain are skipped; defaults to . /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.NotEmpty)] public static Check> IsNotEmpty( this Check> check, bool shortCircuitOnError = false diff --git a/src/Light.PortableResults.Validation/Checks.Enums.cs b/src/Light.PortableResults.Validation/Checks.Enums.cs index eef0cc7..6fdedab 100644 --- a/src/Light.PortableResults.Validation/Checks.Enums.cs +++ b/src/Light.PortableResults.Validation/Checks.Enums.cs @@ -22,6 +22,7 @@ public static partial class Checks /// Uses internally. Flags-combined values that are not themselves /// declared members of are considered invalid. /// + [ValidationRule(ValidationErrorCodes.Enum)] public static Check IsInEnum(this Check check, bool shortCircuitOnError = false) where TEnum : struct, Enum { @@ -96,6 +97,7 @@ public static Check IsInEnum( /// Thrown when the checked nullable value has no value. Guard against this by calling /// before this assertion. /// + [ValidationRule(ValidationErrorCodes.Enum)] public static Check IsInEnum(this Check check, bool shortCircuitOnError = false) where TEnum : struct, Enum { @@ -189,6 +191,8 @@ public static Check IsInEnum( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.EnumName)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.IgnoreCase, nameof(ignoreCase))] public static Check IsEnumName( this Check check, bool ignoreCase = false, diff --git a/src/Light.PortableResults.Validation/Checks.Equality.cs b/src/Light.PortableResults.Validation/Checks.Equality.cs index 77debbf..15f3dd5 100644 --- a/src/Light.PortableResults.Validation/Checks.Equality.cs +++ b/src/Light.PortableResults.Validation/Checks.Equality.cs @@ -20,6 +20,8 @@ public static partial class Checks /// /// The current check for fluent chaining. /// Uses for the comparison. + [ValidationRule(ValidationErrorCodes.EqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsEqualTo( this Check check, T comparativeValue, @@ -159,6 +161,8 @@ public static Check IsEqualTo( /// /// The current check for fluent chaining. /// Uses for the comparison. + [ValidationRule(ValidationErrorCodes.NotEqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsNotEqualTo( this Check check, T comparativeValue, diff --git a/src/Light.PortableResults.Validation/Checks.Null.cs b/src/Light.PortableResults.Validation/Checks.Null.cs index c6e9a9c..0e6e140 100644 --- a/src/Light.PortableResults.Validation/Checks.Null.cs +++ b/src/Light.PortableResults.Validation/Checks.Null.cs @@ -19,6 +19,7 @@ public static partial class Checks /// otherwise. /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.NotNull)] public static Check IsNotNull(this Check check, bool shortCircuitOnError = true) => check.IsShortCircuited || !check.IsValueNull ? check : @@ -72,6 +73,7 @@ public static Check IsNotNull( /// follow IsNull typically expect a value. /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.Null)] public static Check IsNull(this Check check, bool shortCircuitOnError = true) { if (check.IsShortCircuited || check.IsValueNull) diff --git a/src/Light.PortableResults.Validation/Checks.Strings.cs b/src/Light.PortableResults.Validation/Checks.Strings.cs index 232b60d..4f08d29 100644 --- a/src/Light.PortableResults.Validation/Checks.Strings.cs +++ b/src/Light.PortableResults.Validation/Checks.Strings.cs @@ -20,6 +20,7 @@ public static partial class Checks /// assertions in the chain are skipped; defaults to . /// /// The current check for fluent chaining. + [ValidationRule(ValidationErrorCodes.NotNullOrWhiteSpace)] public static Check IsNotNullOrWhiteSpace(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) @@ -100,6 +101,8 @@ public static Check IsNotNullOrWhiteSpace( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.MinLength)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinLength, nameof(minLength))] public static Check HasMinLength( this Check check, int minLength, @@ -192,6 +195,8 @@ public static Check HasMinLength( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.MaxLength)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxLength, nameof(maxLength))] public static Check HasMaxLength( this Check check, int maxLength, @@ -293,6 +298,9 @@ public static Check HasMinLength( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.LengthInRange)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinLength, nameof(minLength))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxLength, nameof(maxLength))] public static Check HasLengthIn( this Check check, int minLength, @@ -518,6 +526,9 @@ public static Check Matches( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.Pattern)] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.Pattern, nameof(pattern))] + [ValidationRuleMetadata(ValidationErrorMetadataKeys.RegexOptions, nameof(options))] public static Check Matches( this Check check, string pattern, @@ -631,6 +642,7 @@ public static Check Matches( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.Email)] public static Check IsEmail(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) @@ -713,6 +725,7 @@ public static Check IsEmail( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.DigitsOnly)] public static Check ContainsOnlyDigits(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) @@ -793,6 +806,7 @@ public static Check ContainsOnlyDigits( /// is converted to before this assertion, /// so this only occurs when using a no-op normalizer. /// + [ValidationRule(ValidationErrorCodes.LettersAndDigitsOnly)] public static Check ContainsOnlyLettersAndDigits(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs index 334e9f7..16ac103 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Comparable.cs @@ -128,13 +128,13 @@ T comparativeValue /// /// Gets or creates a reusable definition for inclusive-range validation failures. /// - public static InBetweenValidationErrorDefinition IsInBetween(T lowerBoundary, T upperBoundary) => - IsInBetween(ValidationErrorDefinitionCache.Default, lowerBoundary, upperBoundary); + public static InRangeValidationErrorDefinition IsInRange(T lowerBoundary, T upperBoundary) => + IsInRange(ValidationErrorDefinitionCache.Default, lowerBoundary, upperBoundary); /// /// Gets or creates a reusable definition for inclusive-range validation failures. /// - public static InBetweenValidationErrorDefinition IsInBetween( + public static InRangeValidationErrorDefinition IsInRange( IValidationErrorDefinitionCache cache, T lowerBoundary, T upperBoundary @@ -157,20 +157,20 @@ T upperBoundary return cache.GetOrAdd( new RangeDefinitionCacheKey(new ValidationRange(lowerBoundary, upperBoundary)), - static key => new InBetweenValidationErrorDefinition(key.Range.LowerBoundary, key.Range.UpperBoundary) + static key => new InRangeValidationErrorDefinition(key.Range.LowerBoundary, key.Range.UpperBoundary) ); } /// /// Gets or creates a reusable definition for outside-range validation failures. /// - public static NotInBetweenValidationErrorDefinition IsNotInBetween(T lowerBoundary, T upperBoundary) => - IsNotInBetween(ValidationErrorDefinitionCache.Default, lowerBoundary, upperBoundary); + public static NotInRangeValidationErrorDefinition IsNotInRange(T lowerBoundary, T upperBoundary) => + IsNotInRange(ValidationErrorDefinitionCache.Default, lowerBoundary, upperBoundary); /// /// Gets or creates a reusable definition for outside-range validation failures. /// - public static NotInBetweenValidationErrorDefinition IsNotInBetween( + public static NotInRangeValidationErrorDefinition IsNotInRange( IValidationErrorDefinitionCache cache, T lowerBoundary, T upperBoundary @@ -193,7 +193,7 @@ T upperBoundary return cache.GetOrAdd( new RangeDefinitionCacheKey(new ValidationRange(lowerBoundary, upperBoundary)), - static key => new NotInBetweenValidationErrorDefinition(key.Range.LowerBoundary, key.Range.UpperBoundary) + static key => new NotInRangeValidationErrorDefinition(key.Range.LowerBoundary, key.Range.UpperBoundary) ); } @@ -389,12 +389,12 @@ in ValidationErrorMessageContext context /// /// Reusable built-in validation error definition for inclusive-range validation failures. /// - public sealed class InBetweenValidationErrorDefinition : ValidationErrorDefinition> + public sealed class InRangeValidationErrorDefinition : ValidationErrorDefinition> { /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// - public InBetweenValidationErrorDefinition(T lowerBoundary, T upperBoundary) + public InRangeValidationErrorDefinition(T lowerBoundary, T upperBoundary) : base( new ValidationRange(lowerBoundary, upperBoundary), code: ValidationErrorCodes.InRange, @@ -419,24 +419,24 @@ public InBetweenValidationErrorDefinition(T lowerBoundary, T upperBoundary) public override bool TryGetStableMessageProvider( ReadOnlyValidationContext context, out object provider - ) => TryGetStableProvider(context.ErrorTemplates.IsInBetween, out provider); + ) => TryGetStableProvider(context.ErrorTemplates.IsInRange, out provider); /// public override ValidationErrorMessage ProvideMessage( in ValidationErrorMessageContext context ) => - context.ValidationContext.ErrorTemplates.IsInBetween.ProvideMessage(in context, LowerBoundary, UpperBoundary); + context.ValidationContext.ErrorTemplates.IsInRange.ProvideMessage(in context, LowerBoundary, UpperBoundary); } /// /// Reusable built-in validation error definition for outside-range validation failures. /// - public sealed class NotInBetweenValidationErrorDefinition : ValidationErrorDefinition> + public sealed class NotInRangeValidationErrorDefinition : ValidationErrorDefinition> { /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// - public NotInBetweenValidationErrorDefinition(T lowerBoundary, T upperBoundary) + public NotInRangeValidationErrorDefinition(T lowerBoundary, T upperBoundary) : base( new ValidationRange(lowerBoundary, upperBoundary), code: ValidationErrorCodes.NotInRange, @@ -461,13 +461,13 @@ public NotInBetweenValidationErrorDefinition(T lowerBoundary, T upperBoundary) public override bool TryGetStableMessageProvider( ReadOnlyValidationContext context, out object provider - ) => TryGetStableProvider(context.ErrorTemplates.NotInBetween, out provider); + ) => TryGetStableProvider(context.ErrorTemplates.NotInRange, out provider); /// public override ValidationErrorMessage ProvideMessage( in ValidationErrorMessageContext context ) => - context.ValidationContext.ErrorTemplates.NotInBetween.ProvideMessage(in context, LowerBoundary, UpperBoundary); + context.ValidationContext.ErrorTemplates.NotInRange.ProvideMessage(in context, LowerBoundary, UpperBoundary); } /// diff --git a/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs b/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs new file mode 100644 index 0000000..6d6bd5c --- /dev/null +++ b/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs @@ -0,0 +1,195 @@ +using System; + +namespace Light.PortableResults.Validation.Definitions; + +/// +/// Describes how a validation rule's metadata contract is represented for compile-time documentation. +/// +public enum ValidationRuleMetadataShape +{ + /// + /// The rule is documented through the globally registered or endpoint-inline metadata contract for its code. + /// + Registered = 0, + + /// + /// The rule has one comparative metadata value whose CLR type is supplied by the validated check type. + /// + TypedComparison = 1, + + /// + /// The rule has lower and upper boundary metadata values whose CLR type is supplied by the validated check type. + /// + TypedRange = 2 +} + +/// +/// Marks a validation check method as emitting a documentable validation error code. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ValidationRuleAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public ValidationRuleAttribute(string errorCode) + { + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(errorCode, nameof(errorCode)); + ErrorCode = errorCode; + } + + /// + /// Initializes a new instance of . + /// + public ValidationRuleAttribute(string errorCode, ValidationRuleMetadataShape metadataShape) + : this(errorCode) => + MetadataShape = metadataShape; + + /// + /// Gets the emitted validation error code. + /// + public string ErrorCode { get; } + + /// + /// Gets the metadata shape the source generator should use for this rule. + /// + public ValidationRuleMetadataShape MetadataShape { get; } = ValidationRuleMetadataShape.Registered; + + /// + /// Gets or sets the validation error definition type that declares this rule's metadata contract. + /// + public Type? ErrorDefinitionType { get; set; } +} + +/// +/// Binds one metadata property emitted by a validation rule to a check-method argument or a fixed value. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class ValidationRuleMetadataAttribute : Attribute +{ + /// + /// Initializes a new instance of bound to fixed attribute values. + /// + public ValidationRuleMetadataAttribute(string metadataKey) + { + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(metadataKey, nameof(metadataKey)); + MetadataKey = metadataKey; + } + + /// + /// Initializes a new instance of bound to a source argument. + /// + public ValidationRuleMetadataAttribute(string metadataKey, string sourceArgument) + { + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(metadataKey, nameof(metadataKey)); + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(sourceArgument, nameof(sourceArgument)); + + MetadataKey = metadataKey; + SourceArgument = sourceArgument; + } + + /// + /// Gets the metadata key. + /// + public string MetadataKey { get; } + + /// + /// Gets the check-method parameter name that supplies this metadata value. + /// + public string? SourceArgument { get; } + + /// + /// Gets or sets a fixed string metadata value. + /// + public string? ConstantStringValue { get; set; } + + /// + /// Gets or sets a fixed integer metadata value. + /// + public long ConstantInt64Value { get; set; } + + /// + /// Gets or sets a value indicating whether is set. + /// + public bool HasConstantInt64Value { get; set; } + + /// + /// Gets or sets a fixed Boolean metadata value. + /// + public bool ConstantBooleanValue { get; set; } + + /// + /// Gets or sets a value indicating whether is set. + /// + public bool HasConstantBooleanValue { get; set; } + + /// + /// Gets or sets a fixed type metadata value. + /// + public Type? ConstantTypeValue { get; set; } +} + +/// +/// Marks a validation error definition as having a stable documentable error contract. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ValidationErrorContractAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public ValidationErrorContractAttribute(string errorCode) + { + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(errorCode, nameof(errorCode)); + ErrorCode = errorCode; + } + + /// + /// Gets the validation error code. + /// + public string ErrorCode { get; } +} + +/// +/// Describes one metadata property exposed by a validation error contract. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class ValidationErrorMetadataContractAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public ValidationErrorMetadataContractAttribute(string metadataKey, Type metadataType) + { + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(metadataKey, nameof(metadataKey)); + if (metadataType is null) + { + throw new ArgumentNullException(nameof(metadataType)); + } + + MetadataKey = metadataKey; + MetadataType = metadataType; + } + + /// + /// Gets the metadata key. + /// + public string MetadataKey { get; } + + /// + /// Gets the metadata CLR type. + /// + public Type MetadataType { get; } + +} + +internal static class ValidationAttributeGuard +{ + public static void EnsureNotNullOrWhiteSpace(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("The value must not be null, empty, or whitespace.", parameterName); + } + } +} diff --git a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs index ce2b9c0..c6e4446 100644 --- a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs +++ b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs @@ -41,10 +41,10 @@ public sealed partial record ValidationErrorTemplates private static readonly IComparableValidationErrorMessageTemplate DefaultLessThanOrEqualToTemplate = new DisplayNameWithComparable(" must be less than or equal to "); - private static readonly IRangeValidationErrorMessageTemplate DefaultIsInBetweenTemplate = + private static readonly IRangeValidationErrorMessageTemplate DefaultIsInRangeTemplate = new DisplayNameWithRange(" must be between ", " and "); - private static readonly IRangeValidationErrorMessageTemplate DefaultNotInBetweenTemplate = + private static readonly IRangeValidationErrorMessageTemplate DefaultNotInRangeTemplate = new DisplayNameWithRange(" must not be between ", " and "); private static readonly IRangeValidationErrorMessageTemplate DefaultExclusiveRangeTemplate = @@ -126,8 +126,8 @@ private ValidationErrorTemplates(ValidationErrorTemplates original) GreaterThanOrEqualTo = original.GreaterThanOrEqualTo; LessThan = original.LessThan; LessThanOrEqualTo = original.LessThanOrEqualTo; - IsInBetween = original.IsInBetween; - NotInBetween = original.NotInBetween; + IsInRange = original.IsInRange; + NotInRange = original.NotInRange; ExclusiveRange = original.ExclusiveRange; MinLength = original.MinLength; MaxLength = original.MaxLength; @@ -281,20 +281,20 @@ public IComparableValidationErrorMessageTemplate LessThanOrEqualTo /// /// Gets the template for inclusive-range validation failures. /// - public IRangeValidationErrorMessageTemplate IsInBetween + public IRangeValidationErrorMessageTemplate IsInRange { get; init => field = value ?? throw new ArgumentNullException(nameof(value)); - } = DefaultIsInBetweenTemplate; + } = DefaultIsInRangeTemplate; /// /// Gets the template for outside-range validation failures. /// - public IRangeValidationErrorMessageTemplate NotInBetween + public IRangeValidationErrorMessageTemplate NotInRange { get; init => field = value ?? throw new ArgumentNullException(nameof(value)); - } = DefaultNotInBetweenTemplate; + } = DefaultNotInRangeTemplate; /// /// Gets the template for exclusive-range validation failures. diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests.csproj b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests.csproj new file mode 100644 index 0000000..0e19959 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests.csproj @@ -0,0 +1,31 @@ + + + + Exe + false + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs new file mode 100644 index 0000000..4b72885 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; +using Light.PortableResults.Validation.OpenApi.SourceGeneration; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class PortableValidationOpenApiGeneratorTests +{ + [Fact] + public void Generator_ShouldEmitContractForSupportedValidator() + { + var result = RunGenerator( + """ + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + namespace TestApp; + + public sealed class RatingDto + { + public Guid Id { get; init; } + public string Comment { get; set; } = ""; + public int Rating { get; init; } + } + + [GeneratePortableValidationOpenApi] + public sealed partial class RatingValidator : Validator + { + public RatingValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + RatingDto dto + ) + { + context.Check(dto.Id).IsNotEmpty(); + dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); + context.Check(dto.Rating).IsInRange(1, 5); + return checkpoint.ToValidatedValue(dto); + } + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain("partial class RatingValidator : IPortableValidationOpenApiContract"); + source.Should().Contain("builder.WithErrorCodes(\"LengthInRange\", \"NotEmpty\");"); + source.Should().Contain("builder.WithInRangeError();"); + source.Should().Contain("builder.WithErrorExample(\"NotEmpty\", \"id\");"); + source.Should().Contain("[\"minLength\"] = 10"); + source.Should().Contain("[\"upperBoundary\"] = 5"); + } + + [Fact] + public void Generator_ShouldReportDiagnosticForNonPartialValidator() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed class NonPartialValidator : Validator + { + public NonPartialValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0001"); + result.GeneratedSources.Should().BeEmpty(); + } + + [Fact] + public void Generator_ShouldWarnForNestedChecksAndOpaqueMust() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class OpaqueValidator : Validator + { + public OpaqueValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) + { + if (value.Length > 0) + { + context.Check(value).IsNotEmpty(); + } + + context.Check(value).Must(static x => x.Length > 0); + return checkpoint.ToValidatedValue(value); + } + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id) + .Should() + .Contain(["LPRSG0005", "LPRSG0006"]); + } + + [Fact] + public void Generator_ShouldEmitInlineSchemaForAnnotatedCustomRule() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.Definitions; + using Light.PortableResults.Validation.Messaging; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class CustomRuleValidator : Validator + { + public CustomRuleValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + int value + ) + { + context.Check(value).IsDivisibleBy(3); + return checkpoint.ToValidatedValue(value); + } + } + + public static class CustomRuleChecks + { + [ValidationRule("DivisibleBy", ErrorDefinitionType = typeof(DivisibleByDefinition))] + [ValidationRuleMetadata("divisor", "divisor")] + public static Check IsDivisibleBy(this Check check, int divisor) => check; + } + + [ValidationErrorContract("DivisibleBy")] + [ValidationErrorMetadataContract("divisor", typeof(int))] + public sealed class DivisibleByDefinition : ValidationErrorDefinition + { + public override ValidationErrorMessage ProvideMessage( + in ValidationErrorMessageContext context + ) => new("The value must be divisible by the configured divisor."); + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain("builder.WithErrorMetadata(\"DivisibleBy\", _ => new OpenApiSchema"); + source.Should().Contain("[\"divisor\"] = PortableOpenApiSchemaTypeMapper.Map()"); + source.Should().Contain("builder.WithErrorExample(\"DivisibleBy\", null, new Dictionary(StringComparer.Ordinal) { [\"divisor\"] = 3 });"); + } + + [Fact] + public void Generator_ShouldApplyMethodLevelErrorHints() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class MethodHintValidator : Validator + { + public MethodHintValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + [PortableValidationOpenApiErrorHint("CustomCode")] + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.GeneratedSources.Single().Should().Contain("builder.WithErrorCodes(\"CustomCode\");"); + } + + private static GeneratorRunResult RunGenerator(string source) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); + var compilation = CSharpCompilation.Create( + "GeneratorTests", + [syntaxTree], + CreateMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + var generator = new PortableValidationOpenApiGenerator(); + var driver = CSharpGeneratorDriver.Create(generator) + .WithUpdatedParseOptions(parseOptions) + .RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics + ); + + var runResult = driver.GetRunResult(); + var diagnostics = outputCompilation.GetDiagnostics() + .Concat(generatorDiagnostics) + .Concat(runResult.Diagnostics) + .ToImmutableArray(); + var generatedSources = runResult.Results + .SelectMany(static result => result.GeneratedSources) + .Select(static sourceText => sourceText.SourceText.ToString()) + .ToImmutableArray(); + return new GeneratorRunResult(diagnostics, generatedSources); + } + + private static MetadataReference[] CreateMetadataReferences() + { + var trustedPlatformAssemblies = ((string?) AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))! + .Split(Path.PathSeparator); + var references = new HashSet(trustedPlatformAssemblies, StringComparer.OrdinalIgnoreCase); + AddAssembly(references, typeof(ValidationContext).Assembly); + AddAssembly(references, typeof(GeneratePortableValidationOpenApiAttribute).Assembly); + AddAssembly(references, typeof(PortableOpenApiSchemaTypeMapper).Assembly); + AddAssembly(references, typeof(OpenApiSchema).Assembly); + AddAssembly(references, typeof(PortableValidationOpenApiGenerator).Assembly); + return references.Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + } + + private static void AddAssembly(ISet references, System.Reflection.Assembly assembly) + { + if (!string.IsNullOrWhiteSpace(assembly.Location)) + { + references.Add(assembly.Location); + } + } + + private sealed class GeneratorRunResult + { + public GeneratorRunResult( + ImmutableArray diagnostics, + ImmutableArray generatedSources + ) + { + Diagnostics = diagnostics; + GeneratedSources = generatedSources; + } + + public ImmutableArray Diagnostics { get; } + public ImmutableArray GeneratedSources { get; } + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/packages.lock.json b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/packages.lock.json new file mode 100644 index 0000000..a018c23 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/packages.lock.json @@ -0,0 +1,336 @@ +{ + "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.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]" + } + }, + "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.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow==" + }, + "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.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, )", + "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.5.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "light.portableresults.aspnetcore.shared": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.5.0, )" + } + }, + "light.portableresults.validation": { + "type": "Project", + "dependencies": { + "Light.PortableResults": "[0.5.0, )" + } + }, + "light.portableresults.validation.openapi": { + "type": "Project", + "dependencies": { + "Light.PortableResults.AspNetCore.OpenApi": "[0.5.0, )", + "Light.PortableResults.Validation": "[0.5.0, )" + } + }, + "light.portableresults.validation.openapi.sourcegeneration": { + "type": "Project" + }, + "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==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[3.11.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "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/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/xunit.runner.json b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/xunit.runner.json new file mode 100644 index 0000000..588a896 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": true, + "parallelizeTestCollections": true +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs new file mode 100644 index 0000000..a4296fc --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.Validation; +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 GeneratedValidationOpenApiIntegrationTests +{ + [Fact] + public async Task ProducesPortableValidationProblemFor_ShouldApplyGeneratedSchemasAndExamples() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapPost("/generated-validation", static () => Results.BadRequest()) + .WithName("GeneratedValidation") + .ProducesPortableValidationProblemFor( + configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich) + ) + .ProducesPortableProblem(); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var operation = document.Paths["/generated-validation"].Operations![HttpMethod.Post]; + var response = (OpenApiResponse) operation.Responses![StatusCodes.Status400BadRequest.ToString()]; + var mediaType = response.Content!["application/problem+json"]; + var schemaReference = (OpenApiSchemaReference) mediaType.Schema!; + var envelope = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(schemaReference) + ); + var errors = (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errors"]).Items!; + + errors.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo( + "PortableError__LengthInRange", + "PortableError__NotEmpty", + "PortableError__GeneratedValidation__400__application_problem_json__InRange" + ); + + mediaType.Examples.Should().ContainKey("ValidationProblem"); + var example = (OpenApiExample) mediaType.Examples["ValidationProblem"]; + var body = example.Value.Should().BeOfType().Subject; + var exampleErrors = body["errors"].Should().BeOfType().Subject; + exampleErrors.ToJsonString().Should().Contain("\"lowerBoundary\":1"); + exampleErrors.ToJsonString().Should().Contain("\"upperBoundary\":5"); + exampleErrors.ToJsonString().Should().Contain("\"minLength\":10"); + exampleErrors.ToJsonString().Should().Contain("\"maxLength\":1000"); + + var genericProblemResponse = (OpenApiResponse) operation.Responses![StatusCodes.Status500InternalServerError.ToString()]; + genericProblemResponse.Content!["application/problem+json"].Examples.Should().BeNullOrEmpty(); + } +} + +public sealed class GeneratedRatingDto +{ + public Guid Id { get; init; } + public string Comment { get; set; } = ""; + public int Rating { get; init; } +} + +[GeneratePortableValidationOpenApi] +public sealed partial class GeneratedRatingValidator : Validator +{ + public GeneratedRatingValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + GeneratedRatingDto dto + ) + { + context.Check(dto.Id).IsNotEmpty(); + dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); + context.Check(dto.Rating).IsInRange(1, 5); + return checkpoint.ToValidatedValue(dto); + } +} 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 index a9adbc6..c2ba95c 100644 --- 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 @@ -27,6 +27,11 @@ + diff --git a/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs b/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs index 3098a6c..19c6d79 100644 --- a/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/BuiltInRuleFamilyWorkflowTests.cs @@ -759,7 +759,7 @@ public void IsIn_ShouldThrow_WhenLowerBoundaryIsNull() { var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); - Action act = () => context.Check("AB", target: "rangeCode").IsInBetween(null!, "ZZ"); + Action act = () => context.Check("AB", target: "rangeCode").IsInRange(null!, "ZZ"); act.Should().Throw().WithParameterName("lowerBoundary"); } @@ -769,7 +769,7 @@ public void IsNotIn_ShouldThrow_WhenUpperBoundaryIsNull() { var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); - Action act = () => context.Check("AB", target: "rangeCode").IsNotInBetween("AA", null!); + Action act = () => context.Check("AB", target: "rangeCode").IsNotInRange("AA", null!); act.Should().Throw().WithParameterName("upperBoundary"); } diff --git a/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs b/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs index 98ed4b4..c865652 100644 --- a/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/CheckOverloadCoverageTests.cs @@ -284,7 +284,7 @@ public void IsIn_ShouldAddError_WhenValueIsOutsideRange() context .Check(1, target: "inRange", displayName: "In range") - .IsInBetween(2, 3, new ErrorOverrides { Code = "InRangeCustom" }); + .IsInRange(2, 3, new ErrorOverrides { Code = "InRangeCustom" }); context.Errors.Should().ContainSingle(error => error.Target == "inRange" && error.Code == "InRangeCustom"); } @@ -294,7 +294,7 @@ public void IsNotIn_ShouldAddError_WhenValueIsInsideRange() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); - context.Check(2, target: "notInRange", displayName: "Not in range").IsNotInBetween(1, 3); + context.Check(2, target: "notInRange", displayName: "Not in range").IsNotInRange(1, 3); context.Errors.Should().ContainSingle(error => error.Target == "notInRange" && error.Code == "NotInRange"); } @@ -414,7 +414,7 @@ public void IsIn_ShouldAddError_WhenValueIsOutsideRange_DefaultOverrides() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); - context.Check(1, target: "inRangeDefault", displayName: "In range default").IsInBetween(2, 3); + context.Check(1, target: "inRangeDefault", displayName: "In range default").IsInRange(2, 3); context.Errors.Should().ContainSingle(error => error.Target == "inRangeDefault" && error.Code == "InRange"); } @@ -426,7 +426,7 @@ public void IsNotIn_ShouldAddError_WhenValueIsInsideRange_WithOverrides() context .Check(2, target: "notInRangeOverride", displayName: "Not in range override") - .IsNotInBetween(1, 3, new ErrorOverrides { Code = "NotInRangeOverride" }); + .IsNotInRange(1, 3, new ErrorOverrides { Code = "NotInRangeOverride" }); context.Errors.Should().ContainSingle( error => error.Target == "notInRangeOverride" && error.Code == "NotInRangeOverride" @@ -928,7 +928,7 @@ public void SuccessPaths_ShouldNotAddErrors() context.Check("user@example.com", target: "email", displayName: "Email").IsEmail(); context.Check(3, target: "greaterThan", displayName: "Greater than").IsGreaterThan(2); - context.Check(3, target: "inRange", displayName: "In range").IsInBetween(1, 5); + context.Check(3, target: "inRange", displayName: "In range").IsInRange(1, 5); context.Check("", target: "emptyString", displayName: "Empty string").IsEmpty(); context .Check>([], target: "emptyCollection", displayName: "Empty collection") @@ -1030,7 +1030,7 @@ public void WrapperSuccessPaths_ShouldNotAddErrors() context .Check(2, target: "lessThanOrEqualOverride", displayName: "Less than or equal override") .IsLessThanOrEqualTo(2, new ErrorOverrides { Code = "Unused" }); - context.Check(2, target: "notIn", displayName: "Not in").IsNotInBetween(3, 5); + context.Check(2, target: "notIn", displayName: "Not in").IsNotInRange(3, 5); context.Check(3, target: "exclusiveRange", displayName: "Exclusive range").IsInExclusiveRange(2, 4); context @@ -1107,10 +1107,10 @@ public void ComparableAndRangeSuccessPaths_ShouldNotAddErrors() context .Check(5, target: "inRangeOverride", displayName: "In range override") - .IsInBetween(1, 5, new ErrorOverrides { Code = "Unused" }); + .IsInRange(1, 5, new ErrorOverrides { Code = "Unused" }); context .Check(0, target: "notInRangeOverride", displayName: "Not in range override") - .IsNotInBetween(1, 5, new ErrorOverrides { Code = "Unused" }); + .IsNotInRange(1, 5, new ErrorOverrides { Code = "Unused" }); context .Check(3, target: "exclusiveRangeOverride", displayName: "Exclusive range override") .IsInExclusiveRange(1, 5, new ErrorOverrides { Code = "Unused" }); diff --git a/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs b/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs index 7eb90c3..eada747 100644 --- a/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/CheckShortCircuitCoverageTests.cs @@ -371,7 +371,7 @@ public void IsIn_ShouldRespectShortCircuit() var context = CreateContext(); var check = context.Check(null, NoOpValueNormalizer.Instance, target: "code").ShortCircuit(); - check.IsInBetween("A", "Z").IsShortCircuited.Should().BeTrue(); + check.IsInRange("A", "Z").IsShortCircuited.Should().BeTrue(); } [Fact] @@ -380,7 +380,7 @@ public void IsIn_ShouldRespectShortCircuit_WhenOverridesAreUsed() var context = CreateContext(); var check = context.Check(null, NoOpValueNormalizer.Instance, target: "code").ShortCircuit(); - check.IsInBetween("A", "Z", new ErrorOverrides { Code = "UnusedIn" }).IsShortCircuited.Should().BeTrue(); + check.IsInRange("A", "Z", new ErrorOverrides { Code = "UnusedIn" }).IsShortCircuited.Should().BeTrue(); } [Fact] @@ -389,7 +389,7 @@ public void IsNotIn_ShouldRespectShortCircuit() var context = CreateContext(); var check = context.Check(null, NoOpValueNormalizer.Instance, target: "code").ShortCircuit(); - check.IsNotInBetween("A", "Z").IsShortCircuited.Should().BeTrue(); + check.IsNotInRange("A", "Z").IsShortCircuited.Should().BeTrue(); } [Fact] @@ -398,7 +398,7 @@ public void IsNotIn_ShouldRespectShortCircuit_WhenOverridesAreUsed() var context = CreateContext(); var check = context.Check(null, NoOpValueNormalizer.Instance, target: "code").ShortCircuit(); - check.IsNotInBetween("A", "Z", new ErrorOverrides { Code = "UnusedNotIn" }).IsShortCircuited.Should().BeTrue(); + check.IsNotInRange("A", "Z", new ErrorOverrides { Code = "UnusedNotIn" }).IsShortCircuited.Should().BeTrue(); } [Fact] diff --git a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs index fa926f7..31ceff8 100644 --- a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs @@ -32,8 +32,8 @@ 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); + BuiltInValidationErrorDefinitions.IsInRange(1, 5).Code.Should().Be(ValidationErrorCodes.InRange); + BuiltInValidationErrorDefinitions.IsNotInRange(1, 5).Code.Should().Be(ValidationErrorCodes.NotInRange); } [Fact] @@ -174,7 +174,7 @@ public void LessThanOrEqualTo_ShouldExposeExpectedMetadata() [Fact] public void IsIn_ShouldExposeExpectedMetadata() { - var inRange = BuiltInValidationErrorDefinitions.IsInBetween(6UL, 7UL); + var inRange = BuiltInValidationErrorDefinitions.IsInRange(6UL, 7UL); inRange.Metadata.Should().Be( MetadataObject.Create( (ValidationErrorMetadataKeys.LowerBoundary, "6"), @@ -186,7 +186,7 @@ public void IsIn_ShouldExposeExpectedMetadata() [Fact] public void IsNotIn_ShouldExposeExpectedMetadata() { - var notInRange = BuiltInValidationErrorDefinitions.IsNotInBetween('A', 'Z'); + var notInRange = BuiltInValidationErrorDefinitions.IsNotInRange('A', 'Z'); notInRange.Metadata.Should().Be( MetadataObject.Create( (ValidationErrorMetadataKeys.LowerBoundary, "A"), @@ -310,8 +310,8 @@ public void ComparableDefinitions_ShouldExposeStableProviders() var greaterThanOrEqualTo = BuiltInValidationErrorDefinitions.GreaterThanOrEqualTo(18); var lessThan = BuiltInValidationErrorDefinitions.LessThan(18); var lessThanOrEqualTo = BuiltInValidationErrorDefinitions.LessThanOrEqualTo(18); - var inRange = BuiltInValidationErrorDefinitions.IsInBetween(1, 10); - var notInRange = BuiltInValidationErrorDefinitions.IsNotInBetween(1, 10); + var inRange = BuiltInValidationErrorDefinitions.IsInRange(1, 10); + var notInRange = BuiltInValidationErrorDefinitions.IsNotInRange(1, 10); var exclusiveRange = BuiltInValidationErrorDefinitions.IsInExclusiveRange(1, 10); greaterThan.TryGetStableMessageProvider(readOnlyContext, out var greaterThanProvider).Should().BeTrue(); @@ -328,8 +328,8 @@ public void ComparableDefinitions_ShouldExposeStableProviders() greaterThanOrEqualProvider.Should().BeSameAs(context.ErrorTemplates.GreaterThanOrEqualTo); lessThanProvider.Should().BeSameAs(context.ErrorTemplates.LessThan); lessThanOrEqualProvider.Should().BeSameAs(context.ErrorTemplates.LessThanOrEqualTo); - inRangeProvider.Should().BeSameAs(context.ErrorTemplates.IsInBetween); - notInRangeProvider.Should().BeSameAs(context.ErrorTemplates.NotInBetween); + inRangeProvider.Should().BeSameAs(context.ErrorTemplates.IsInRange); + notInRangeProvider.Should().BeSameAs(context.ErrorTemplates.NotInRange); exclusiveRangeProvider.Should().BeSameAs(context.ErrorTemplates.ExclusiveRange); } @@ -374,7 +374,7 @@ public void IsIn_ShouldProvideExpectedMessage() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); var messageContext = context.Check(10, target: "age", displayName: "Age").CreateMessageContext(); - var inRange = BuiltInValidationErrorDefinitions.IsInBetween(1, 10); + var inRange = BuiltInValidationErrorDefinitions.IsInRange(1, 10); inRange.ProvideMessage(messageContext).Text.Should().Be("Age must be between 1 and 10"); } @@ -383,7 +383,7 @@ public void IsNotIn_ShouldProvideExpectedMessage() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); var messageContext = context.Check(10, target: "age", displayName: "Age").CreateMessageContext(); - var notInRange = BuiltInValidationErrorDefinitions.IsNotInBetween(1, 10); + var notInRange = BuiltInValidationErrorDefinitions.IsNotInRange(1, 10); notInRange.ProvideMessage(messageContext).Text.Should().Be("Age must not be between 1 and 10"); } @@ -413,14 +413,14 @@ public void GreaterThanOrEqualTo_ShouldThrow_WhenCacheIsNull() [Fact] public void IsIn_ShouldThrow_WhenCacheIsNull() { - Action act = () => BuiltInValidationErrorDefinitions.IsInBetween(null!, "A", "Z"); + Action act = () => BuiltInValidationErrorDefinitions.IsInRange(null!, "A", "Z"); act.Should().Throw().WithParameterName("cache"); } [Fact] public void IsNotIn_ShouldThrow_WhenCacheIsNull() { - Action act = () => BuiltInValidationErrorDefinitions.IsNotInBetween(null!, "A", "Z"); + Action act = () => BuiltInValidationErrorDefinitions.IsNotInRange(null!, "A", "Z"); act.Should().Throw().WithParameterName("cache"); } @@ -467,7 +467,7 @@ public void LessThanOrEqualTo_ShouldThrow_WhenValueIsNull() public void IsIn_ShouldThrow_WhenLowerBoundaryIsNull() { var cache = new ValidationErrorDefinitionCache(); - Action act = () => BuiltInValidationErrorDefinitions.IsInBetween(cache, null!, "Z"); + Action act = () => BuiltInValidationErrorDefinitions.IsInRange(cache, null!, "Z"); act.Should().Throw().WithParameterName("lowerBoundary"); } @@ -475,7 +475,7 @@ public void IsIn_ShouldThrow_WhenLowerBoundaryIsNull() public void IsNotIn_ShouldThrow_WhenLowerBoundaryIsNull() { var cache = new ValidationErrorDefinitionCache(); - Action act = () => BuiltInValidationErrorDefinitions.IsNotInBetween(cache, null!, "Z"); + Action act = () => BuiltInValidationErrorDefinitions.IsNotInRange(cache, null!, "Z"); act.Should().Throw().WithParameterName("lowerBoundary"); } @@ -491,7 +491,7 @@ public void IsInExclusiveRange_ShouldThrow_WhenLowerBoundaryIsNull() public void IsNotIn_ShouldThrow_WhenUpperBoundaryIsNull() { var cache = new ValidationErrorDefinitionCache(); - Action act = () => BuiltInValidationErrorDefinitions.IsNotInBetween(cache, "A", null!); + Action act = () => BuiltInValidationErrorDefinitions.IsNotInRange(cache, "A", null!); act.Should().Throw().WithParameterName("upperBoundary"); } @@ -833,7 +833,7 @@ public void GreaterThan_ShouldReportUnstableProviders_WhenTemplatesAreNotStable( public void IsIn_ShouldReportUnstableProviders_WhenTemplatesAreNotStable() { var context = CreateContextWithUnstableTemplates().AsReadOnly(); - BuiltInValidationErrorDefinitions.IsInBetween(1, 10) + BuiltInValidationErrorDefinitions.IsInRange(1, 10) .TryGetStableMessageProvider(context, out var provider) .Should().BeFalse(); provider.Should().BeNull(); @@ -915,7 +915,7 @@ private static ValidationContext CreateContextWithUnstableTemplates() ValidationErrorTemplates.Default with { GreaterThan = new UnstableComparableTemplate(), - IsInBetween = new UnstableRangeTemplate(), + IsInRange = new UnstableRangeTemplate(), Count = new UnstableIntTemplate(), MinCount = new UnstableIntTemplate(), MaxCount = new UnstableIntTemplate(), @@ -960,8 +960,8 @@ public void GreaterThan_ShouldBeReused_WhenEquivalent() public void IsIn_ShouldBeReused_WhenEquivalent() { var cache = new ValidationErrorDefinitionCache(); - BuiltInValidationErrorDefinitions.IsInBetween(cache, 1, 10).Should().BeSameAs( - BuiltInValidationErrorDefinitions.IsInBetween(cache, 1, 10) + BuiltInValidationErrorDefinitions.IsInRange(cache, 1, 10).Should().BeSameAs( + BuiltInValidationErrorDefinitions.IsInRange(cache, 1, 10) ); } @@ -969,8 +969,8 @@ public void IsIn_ShouldBeReused_WhenEquivalent() public void IsNotIn_ShouldBeReused_WhenEquivalent() { var cache = new ValidationErrorDefinitionCache(); - BuiltInValidationErrorDefinitions.IsNotInBetween(cache, 1, 10).Should().BeSameAs( - BuiltInValidationErrorDefinitions.IsNotInBetween(cache, 1, 10) + BuiltInValidationErrorDefinitions.IsNotInRange(cache, 1, 10).Should().BeSameAs( + BuiltInValidationErrorDefinitions.IsNotInRange(cache, 1, 10) ); } diff --git a/tests/package-consumer/validate-validation-openapi-source-generation.sh b/tests/package-consumer/validate-validation-openapi-source-generation.sh new file mode 100755 index 0000000..4a88062 --- /dev/null +++ b/tests/package-consumer/validate-validation-openapi-source-generation.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +FEED_DIR="${TMPDIR:-/tmp}/light-portable-results-validation-openapi-feed" +CONSUMER_DIR="${TMPDIR:-/tmp}/light-portable-results-validation-openapi-consumer" +CONFIGURATION="${CONFIGURATION:-Release}" + +rm -rf "${FEED_DIR}" "${CONSUMER_DIR}" +mkdir -p "${FEED_DIR}" "${CONSUMER_DIR}" + +dotnet pack "${REPO_ROOT}/src/Light.PortableResults/Light.PortableResults.csproj" \ + --configuration "${CONFIGURATION}" \ + --output "${FEED_DIR}" +dotnet pack "${REPO_ROOT}/src/Light.PortableResults.AspNetCore.Shared/Light.PortableResults.AspNetCore.Shared.csproj" \ + --configuration "${CONFIGURATION}" \ + --output "${FEED_DIR}" +dotnet pack "${REPO_ROOT}/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj" \ + --configuration "${CONFIGURATION}" \ + --output "${FEED_DIR}" +dotnet pack "${REPO_ROOT}/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj" \ + --configuration "${CONFIGURATION}" \ + --output "${FEED_DIR}" +dotnet pack "${REPO_ROOT}/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj" \ + --configuration "${CONFIGURATION}" \ + --output "${FEED_DIR}" + +dotnet new web --framework net10.0 --output "${CONSUMER_DIR}" + +cat > "${CONSUMER_DIR}/NuGet.config" < + + + + + + + +NUGET + +cat > "${CONSUMER_DIR}/Directory.Build.props" <<'PROPS' + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + +PROPS + +dotnet add "${CONSUMER_DIR}/light-portable-results-validation-openapi-consumer.csproj" package \ + Light.PortableResults.Validation.OpenApi \ + --version 0.5.0 \ + --no-restore + +cat > "${CONSUMER_DIR}/Program.cs" <<'CSHARP' +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Validation; +using Light.PortableResults.Validation.OpenApi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddOpenApi(); +builder.Services.AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors()); + +var app = builder.Build(); +app.MapPost("/ratings", static () => Results.BadRequest()) + .ProducesPortableValidationProblemFor(); + +public sealed class RatingDto +{ + public int Rating { get; init; } +} + +[GeneratePortableValidationOpenApi] +public sealed partial class RatingValidator : Validator +{ + public RatingValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + RatingDto dto + ) + { + context.Check(dto.Rating).IsInRange(1, 5); + return checkpoint.ToValidatedValue(dto); + } +} +CSHARP + +dotnet restore "${CONSUMER_DIR}/light-portable-results-validation-openapi-consumer.csproj" \ + --configfile "${CONSUMER_DIR}/NuGet.config" + +dotnet build "${CONSUMER_DIR}/light-portable-results-validation-openapi-consumer.csproj" \ + --configuration "${CONFIGURATION}" \ + --no-restore From cc308134477705a9fd0447794db1f7ba77f6273a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 15 May 2026 17:57:08 +0200 Subject: [PATCH 06/17] chore: simplify code in ValidatorOpenApiEmitter and ValidatorOpenApiAnalyzer Signed-off-by: Kenny Pflug --- .../ValidatorOpenApiAnalyzer.cs | 81 +++++--- .../ValidatorOpenApiEmitter.cs | 193 ++++++++---------- 2 files changed, 136 insertions(+), 138 deletions(-) diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs index 0d85c90..8e558d4 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -47,7 +47,12 @@ CancellationToken cancellationToken ValidateValidatorShape(validatorType, classDeclaration, diagnostics, cancellationToken); var hasShapeError = diagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); - if (!TryGetPerformValidationMethod(validatorType, cancellationToken, out var performValidation, out var methodDeclaration)) + if (!TryGetPerformValidationMethod( + validatorType, + cancellationToken, + out var performValidation, + out var methodDeclaration + )) { diagnostics.Add( Diagnostic.Create( @@ -65,7 +70,8 @@ CancellationToken cancellationToken } var semanticModel = compilation.GetSemanticModel(methodDeclaration.SyntaxTree); - var sourceParameterName = performValidation.Parameters.Length >= 3 ? performValidation.Parameters[2].Name : null; + var sourceParameterName = + performValidation.Parameters.Length >= 3 ? performValidation.Parameters[2].Name : null; var rules = ImmutableArray.CreateBuilder(); AnalyzePerformValidationBody( semanticModel, @@ -165,8 +171,7 @@ CancellationToken cancellationToken } var baseMetadataName = GetMetadataName(baseType.OriginalDefinition); - if (baseMetadataName == KnownTypeNames.AsyncValidator || - baseMetadataName == KnownTypeNames.TransformingAsyncValidator) + if (baseMetadataName is KnownTypeNames.AsyncValidator or KnownTypeNames.TransformingAsyncValidator) { diagnostics.Add( Diagnostic.Create( @@ -211,7 +216,14 @@ CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); if (TryGetTopLevelCheckExpression(statement, out var expression)) { - AnalyzeCheckExpression(semanticModel, expression, sourceParameterName, rules, diagnostics, cancellationToken); + AnalyzeCheckExpression( + semanticModel, + expression, + sourceParameterName, + rules, + diagnostics, + cancellationToken + ); continue; } @@ -274,7 +286,15 @@ CancellationToken cancellationToken continue; } - var rule = CreateRuleCall(semanticModel, invocation, symbol, ruleAttribute, target, diagnostics, cancellationToken); + var rule = CreateRuleCall( + semanticModel, + invocation, + symbol, + ruleAttribute, + target, + diagnostics, + cancellationToken + ); if (rule is not null) { rules.Add(rule); @@ -392,8 +412,9 @@ out ImmutableArray metadataSchemaProperties var builder = ImmutableArray.CreateBuilder(); metadataSchemaProperties = builder.ToImmutable(); - var errorDefinitionType = ruleAttribute.NamedArguments.FirstOrDefault(static argument => - argument.Key == "ErrorDefinitionType" + var errorDefinitionType = ruleAttribute.NamedArguments.FirstOrDefault( + static argument => + argument.Key == "ErrorDefinitionType" ).Value.Value as INamedTypeSymbol; if (errorDefinitionType is null) { @@ -419,10 +440,12 @@ out ImmutableArray metadataSchemaProperties } foreach (var metadataAttribute in errorDefinitionType.GetAttributes() - .Where(static attribute => IsAttribute( - attribute, - KnownTypeNames.ValidationErrorMetadataContractAttribute - ))) + .Where( + static attribute => IsAttribute( + attribute, + KnownTypeNames.ValidationErrorMetadataContractAttribute + ) + )) { if (metadataAttribute.ConstructorArguments.Length < 2 || metadataAttribute.ConstructorArguments[0].Value is not string metadataKey || @@ -612,7 +635,7 @@ private static bool TryGetTopLevelCheckExpression(StatementSyntax statement, out case ExpressionStatementSyntax expressionStatement: expression = UnwrapAssignment(expressionStatement.Expression); return true; - case LocalDeclarationStatementSyntax localDeclaration when localDeclaration.Declaration.Variables.Count == 1: + case LocalDeclarationStatementSyntax { Declaration.Variables.Count: 1 } localDeclaration: var initializer = localDeclaration.Declaration.Variables[0].Initializer; if (initializer is null) { @@ -640,7 +663,10 @@ private static List CollectInvocationChain(Expressio return invocations; } - private static void CollectInvocationChain(ExpressionSyntax expression, ICollection invocations) + private static void CollectInvocationChain( + ExpressionSyntax expression, + ICollection invocations + ) { if (expression is not InvocationExpressionSyntax invocation) { @@ -741,7 +767,9 @@ private static string ToCamelCase(string name) private static bool UsesErrorOverrides(IMethodSymbol symbol) { - return symbol.Parameters.Any(static parameter => GetMetadataName(parameter.Type) == KnownTypeNames.ErrorOverrides); + return symbol.Parameters.Any( + static parameter => GetMetadataName(parameter.Type) == KnownTypeNames.ErrorOverrides + ); } private static AttributeData? GetAttribute(ISymbol symbol, string metadataName) @@ -799,8 +827,7 @@ private static bool GetAllowUnknownErrorCodes(INamedTypeSymbol validatorType) foreach (var namedArgument in attribute.NamedArguments) { - if (namedArgument.Key == "AllowUnknownErrorCodes" && - namedArgument.Value.Value is bool allowUnknownErrorCodes) + if (namedArgument is { Key: "AllowUnknownErrorCodes", Value.Value: bool allowUnknownErrorCodes }) { return allowUnknownErrorCodes; } @@ -860,22 +887,18 @@ out ClassDeclarationSyntax declaration return false; } - private static string GetAccessibility(Accessibility accessibility) - { - switch (accessibility) + private static string GetAccessibility(Accessibility accessibility) => + accessibility switch { - case Accessibility.Public: - return "public"; - case Accessibility.Internal: - return "internal"; - default: - return "public"; - } - } + Accessibility.Public => "public", + Accessibility.Internal => "internal", + _ => "public" + }; private static string CreateHintName(INamedTypeSymbol validatorType) { - var metadataName = validatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + var metadataName = validatorType + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) .Replace("global::", string.Empty); var chars = metadataName.Select(static c => char.IsLetterOrDigit(c) || c == '.' ? c : '_').ToArray(); return new string(chars) + ".PortableValidationOpenApi.g.cs"; diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 65531ec..b911dda 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -34,7 +34,9 @@ public static string Emit(ValidatorModel model) .Append(" : IPortableValidationOpenApiContract") .AppendLine(); builder.Append(indent).AppendLine("{"); - builder.Append(indent).AppendLine(" public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)"); + builder.Append(indent).AppendLine( + " public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)" + ); builder.Append(indent).AppendLine(" {"); builder.Append(indent).AppendLine(" if (builder is null)"); builder.Append(indent).AppendLine(" {"); @@ -63,9 +65,12 @@ public static string Emit(ValidatorModel model) private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorModel model, string indent) { - var registeredCodes = model.Rules - .Where(static rule => rule.Shape == RuleMetadataShape.Registered && - rule.MetadataSchemaProperties.Length == 0) + var registeredCodes = model + .Rules + .Where( + static rule => rule.Shape == RuleMetadataShape.Registered && + rule.MetadataSchemaProperties.Length == 0 + ) .Select(static rule => rule.Code) .Concat(model.Hints.Where(static hint => hint.MetadataTypeName is null).Select(static hint => hint.Code)) .Distinct(StringComparer.Ordinal) @@ -89,7 +94,8 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode foreach (var hint in model.Hints.Where(static hint => hint.MetadataTypeName is not null)) { - builder.Append(indent) + builder + .Append(indent) .Append("builder.WithErrorMetadata<") .Append(hint.MetadataTypeName) .Append(">(") @@ -98,8 +104,10 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode } foreach (var rule in model.Rules - .Where(static rule => rule.Shape == RuleMetadataShape.Registered && - rule.MetadataSchemaProperties.Length > 0) + .Where( + static rule => rule.Shape == RuleMetadataShape.Registered && + rule.MetadataSchemaProperties.Length > 0 + ) .Distinct(InlineSchemaRuleComparer.Instance) .OrderBy(static rule => rule.Code, StringComparer.Ordinal)) { @@ -118,7 +126,8 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode continue; } - builder.Append(indent) + builder + .Append(indent) .Append("builder.") .Append(helperName) .Append('<') @@ -129,8 +138,10 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode if (registeredCodes.Length > 0 || model.Hints.Length > 0 || model.Rules.Any(static rule => rule.Shape != RuleMetadataShape.Registered) || - model.Rules.Any(static rule => rule.Shape == RuleMetadataShape.Registered && - rule.MetadataSchemaProperties.Length > 0)) + model.Rules.Any( + static rule => rule.Shape == RuleMetadataShape.Registered && + rule.MetadataSchemaProperties.Length > 0 + )) { builder.AppendLine(); } @@ -138,18 +149,25 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode private static void EmitInlineSchemaConfiguration(StringBuilder builder, RuleCallModel rule, string indent) { - builder.Append(indent) + builder + .Append(indent) .Append("builder.WithErrorMetadata(") .Append(ToStringLiteral(rule.Code)) .AppendLine(", _ => new OpenApiSchema"); builder.Append(indent).AppendLine("{"); builder.Append(indent).AppendLine(" Type = JsonSchemaType.Object,"); - builder.Append(indent).AppendLine(" Properties = new Dictionary(StringComparer.Ordinal)"); + builder + .Append(indent) + .AppendLine(" Properties = new Dictionary(StringComparer.Ordinal)"); builder.Append(indent).AppendLine(" {"); - foreach (var property in rule.MetadataSchemaProperties.OrderBy(static property => property.Key, StringComparer.Ordinal)) + foreach (var property in rule.MetadataSchemaProperties.OrderBy( + static property => property.Key, + StringComparer.Ordinal + )) { - builder.Append(indent) + builder + .Append(indent) .Append(" [") .Append(ToStringLiteral(property.Key)) .Append("] = PortableOpenApiSchemaTypeMapper.Map<") @@ -170,7 +188,8 @@ private static void EmitInlineSchemaConfiguration(StringBuilder builder, RuleCal } builder.AppendLine(" }"); - builder.Append(indent) + builder + .Append(indent) .Append("}, ") .Append(ToStringLiteral(rule.Code + "Metadata")) .AppendLine(");"); @@ -227,71 +246,41 @@ private static void EmitMetadataDictionary(StringBuilder builder, IEnumerable + rule.Code switch { - case "EqualTo": - return "WithEqualToError"; - case "NotEqualTo": - return "WithNotEqualToError"; - case "GreaterThan": - return "WithGreaterThanError"; - case "GreaterThanOrEqualTo": - return "WithGreaterThanOrEqualToError"; - case "LessThan": - return "WithLessThanError"; - case "LessThanOrEqualTo": - return "WithLessThanOrEqualToError"; - case "InRange": - return "WithInRangeError"; - case "NotInRange": - return "WithNotInRangeError"; - case "ExclusiveRange": - return "WithExclusiveRangeError"; - default: - return null; - } - } - - private static string ToLiteral(object? value) - { - switch (value) + "EqualTo" => "WithEqualToError", + "NotEqualTo" => "WithNotEqualToError", + "GreaterThan" => "WithGreaterThanError", + "GreaterThanOrEqualTo" => "WithGreaterThanOrEqualToError", + "LessThan" => "WithLessThanError", + "LessThanOrEqualTo" => "WithLessThanOrEqualToError", + "InRange" => "WithInRangeError", + "NotInRange" => "WithNotInRangeError", + "ExclusiveRange" => "WithExclusiveRangeError", + _ => null + }; + + private static string ToLiteral(object? value) => + value switch { - case null: - return "null"; - case string stringValue: - return ToStringLiteral(stringValue); - case char charValue: - return "'" + EscapeChar(charValue) + "'"; - case bool boolValue: - return boolValue ? "true" : "false"; - case byte byteValue: - return byteValue.ToString(CultureInfo.InvariantCulture); - case sbyte sbyteValue: - return sbyteValue.ToString(CultureInfo.InvariantCulture); - case short shortValue: - return shortValue.ToString(CultureInfo.InvariantCulture); - case ushort ushortValue: - return ushortValue.ToString(CultureInfo.InvariantCulture); - case int intValue: - return intValue.ToString(CultureInfo.InvariantCulture); - case uint uintValue: - return uintValue.ToString(CultureInfo.InvariantCulture) + "U"; - case long longValue: - return longValue.ToString(CultureInfo.InvariantCulture) + "L"; - case ulong ulongValue: - return ulongValue.ToString(CultureInfo.InvariantCulture) + "UL"; - case float floatValue: - return floatValue.ToString("R", CultureInfo.InvariantCulture) + "F"; - case double doubleValue: - return doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D"; - case decimal decimalValue: - return decimalValue.ToString(CultureInfo.InvariantCulture) + "M"; - default: - return ToStringLiteral(value.ToString() ?? string.Empty); - } - } + null => "null", + string stringValue => ToStringLiteral(stringValue), + char charValue => "'" + EscapeChar(charValue) + "'", + bool boolValue => boolValue ? "true" : "false", + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture), + short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), + ushort ushortValue => ushortValue.ToString(CultureInfo.InvariantCulture), + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + uint uintValue => uintValue.ToString(CultureInfo.InvariantCulture) + "U", + long longValue => longValue.ToString(CultureInfo.InvariantCulture) + "L", + ulong ulongValue => ulongValue.ToString(CultureInfo.InvariantCulture) + "UL", + float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", + double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", + decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", + _ => ToStringLiteral(value.ToString() ?? string.Empty) + }; private static string ToStringLiteral(string value) { @@ -306,36 +295,22 @@ private static string ToStringLiteral(string value) return builder.ToString(); } - private static string EscapeChar(char value) - { - switch (value) + private static string EscapeChar(char value) => + value switch { - case '\\': - return "\\\\"; - case '"': - return "\\\""; - case '\'': - return "\\'"; - case '\0': - return "\\0"; - case '\a': - return "\\a"; - case '\b': - return "\\b"; - case '\f': - return "\\f"; - case '\n': - return "\\n"; - case '\r': - return "\\r"; - case '\t': - return "\\t"; - case '\v': - return "\\v"; - default: - return char.IsControl(value) ? - "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : - value.ToString(); - } - } + '\\' => @"\\", + '"' => "\\\"", + '\'' => "\\'", + '\0' => "\\0", + '\a' => "\\a", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\v' => "\\v", + _ => char.IsControl(value) ? + "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : + value.ToString() + }; } From cbb8ff85363761699c14dace79bdc94cb814a8db Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 15 May 2026 18:43:41 +0200 Subject: [PATCH 07/17] refactor: introduce CodeWriter in Roslyn Source Generator Signed-off-by: Kenny Pflug --- .../CodeWriter.cs | 102 ++++++++ .../ValidatorOpenApiEmitter.cs | 247 ++++++++---------- 2 files changed, 217 insertions(+), 132 deletions(-) create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CodeWriter.cs diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CodeWriter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CodeWriter.cs new file mode 100644 index 0000000..5cc73e1 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CodeWriter.cs @@ -0,0 +1,102 @@ +using System; +using System.Text; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// A small writer over that tracks indentation while +/// generated source is composed. Indent is written lazily on the first character of +/// each new line, so chained calls produce a single +/// indented line. +/// +internal sealed class CodeWriter +{ + private const string IndentString = " "; + private readonly StringBuilder _builder = new (); + private int _indentLevel; + private bool _isAtLineStart = true; + + public int IndentLevel => _indentLevel; + + public CodeWriter IncreaseIndent() + { + _indentLevel++; + return this; + } + + public CodeWriter DecreaseIndent() + { + if (_indentLevel == 0) + { + throw new InvalidOperationException("Indent level is already zero."); + } + + _indentLevel--; + return this; + } + + public CodeWriter Write(string value) + { + WriteIndentIfAtLineStart(); + _builder.Append(value); + return this; + } + + public CodeWriter Write(char value) + { + WriteIndentIfAtLineStart(); + _builder.Append(value); + return this; + } + + public CodeWriter WriteLine() + { + _builder.AppendLine(); + _isAtLineStart = true; + return this; + } + + public CodeWriter WriteLine(string value) + { + WriteIndentIfAtLineStart(); + _builder.AppendLine(value); + _isAtLineStart = true; + return this; + } + + /// + /// Writes a line containing { at the current indent level and increases + /// the indent so subsequent lines fall inside the block. + /// + public CodeWriter OpenBrace() + { + WriteLine("{"); + return IncreaseIndent(); + } + + /// + /// Decreases the indent level and writes a line containing } at the new level. + /// + public CodeWriter CloseBrace() + { + DecreaseIndent(); + return WriteLine("}"); + } + + public override string ToString() => _builder.ToString(); + + private void WriteIndentIfAtLineStart() + { + if (!_isAtLineStart) + { + return; + } + + for (var i = 0; i < _indentLevel; i++) + { + _builder.Append(IndentString); + } + + _isAtLineStart = false; + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index b911dda..51379d0 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -10,60 +10,59 @@ internal static class ValidatorOpenApiEmitter { public static string Emit(ValidatorModel model) { - var builder = new StringBuilder(); - builder.AppendLine("// "); - builder.AppendLine("#nullable enable"); - builder.AppendLine("using System;"); - builder.AppendLine("using System.Collections.Generic;"); - builder.AppendLine("using Light.PortableResults.AspNetCore.OpenApi;"); - builder.AppendLine("using Light.PortableResults.Validation.OpenApi;"); - builder.AppendLine("using Microsoft.OpenApi;"); - builder.AppendLine(); + var writer = new CodeWriter() + .WriteLine("// ") + .WriteLine("#nullable enable") + .WriteLine("using System;") + .WriteLine("using System.Collections.Generic;") + .WriteLine("using Light.PortableResults.AspNetCore.OpenApi;") + .WriteLine("using Light.PortableResults.Validation.OpenApi;") + .WriteLine("using Microsoft.OpenApi;") + .WriteLine(); if (model.NamespaceName is not null) { - builder.Append("namespace ").Append(model.NamespaceName).AppendLine(); - builder.AppendLine("{"); + writer + .Write("namespace ") + .WriteLine(model.NamespaceName) + .OpenBrace(); } - var indent = model.NamespaceName is null ? "" : " "; - builder.Append(indent) - .Append(model.Accessibility) - .Append(" partial class ") - .Append(model.ClassName) - .Append(" : IPortableValidationOpenApiContract") - .AppendLine(); - builder.Append(indent).AppendLine("{"); - builder.Append(indent).AppendLine( - " public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)" - ); - builder.Append(indent).AppendLine(" {"); - builder.Append(indent).AppendLine(" if (builder is null)"); - builder.Append(indent).AppendLine(" {"); - builder.Append(indent).AppendLine(" throw new ArgumentNullException(nameof(builder));"); - builder.Append(indent).AppendLine(" }"); - builder.AppendLine(); + writer + .Write(model.Accessibility) + .Write(" partial class ") + .Write(model.ClassName) + .WriteLine(" : IPortableValidationOpenApiContract") + .OpenBrace() + .WriteLine( + "public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)" + ) + .OpenBrace() + .WriteLine("if (builder is null)") + .OpenBrace() + .WriteLine("throw new ArgumentNullException(nameof(builder));") + .CloseBrace() + .WriteLine(); - EmitSchemaConfiguration(builder, model, indent + " "); - EmitExamples(builder, model, indent + " "); + EmitSchemaConfiguration(writer, model); + EmitExamples(writer, model); if (model.AllowUnknownErrorCodes) { - builder.Append(indent).AppendLine(" builder.AllowUnknownErrorCodes();"); + writer.WriteLine("builder.AllowUnknownErrorCodes();"); } - builder.Append(indent).AppendLine(" }"); - builder.Append(indent).AppendLine("}"); + writer.CloseBrace().CloseBrace(); if (model.NamespaceName is not null) { - builder.AppendLine("}"); + writer.CloseBrace(); } - return builder.ToString(); + return writer.ToString(); } - private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorModel model, string indent) + private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorModel model) { var registeredCodes = model .Rules @@ -78,29 +77,20 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode .ToArray(); if (registeredCodes.Length > 0) { - builder.Append(indent).Append("builder.WithErrorCodes("); - for (var i = 0; i < registeredCodes.Length; i++) - { - if (i > 0) - { - builder.Append(", "); - } - - builder.Append(ToStringLiteral(registeredCodes[i])); - } - - builder.AppendLine(");"); + writer + .Write("builder.WithErrorCodes(") + .Write(string.Join(", ", registeredCodes.Select(ToStringLiteral))) + .WriteLine(");"); } foreach (var hint in model.Hints.Where(static hint => hint.MetadataTypeName is not null)) { - builder - .Append(indent) - .Append("builder.WithErrorMetadata<") - .Append(hint.MetadataTypeName) - .Append(">(") - .Append(ToStringLiteral(hint.Code)) - .AppendLine(");"); + writer + .Write("builder.WithErrorMetadata<") + .Write(hint.MetadataTypeName!) + .Write(">(") + .Write(ToStringLiteral(hint.Code)) + .WriteLine(");"); } foreach (var rule in model.Rules @@ -111,7 +101,7 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode .Distinct(InlineSchemaRuleComparer.Instance) .OrderBy(static rule => rule.Code, StringComparer.Ordinal)) { - EmitInlineSchemaConfiguration(builder, rule, indent); + EmitInlineSchemaConfiguration(writer, rule); } foreach (var rule in model.Rules @@ -126,13 +116,12 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode continue; } - builder - .Append(indent) - .Append("builder.") - .Append(helperName) - .Append('<') - .Append(rule.TypedValueTypeName) - .AppendLine(">();"); + writer + .Write("builder.") + .Write(helperName) + .Write('<') + .Write(rule.TypedValueTypeName) + .WriteLine(">();"); } if (registeredCodes.Length > 0 || @@ -143,59 +132,56 @@ private static void EmitSchemaConfiguration(StringBuilder builder, ValidatorMode rule.MetadataSchemaProperties.Length > 0 )) { - builder.AppendLine(); + writer.WriteLine(); } + + return writer; } - private static void EmitInlineSchemaConfiguration(StringBuilder builder, RuleCallModel rule, string indent) + private static CodeWriter EmitInlineSchemaConfiguration(CodeWriter writer, RuleCallModel rule) { - builder - .Append(indent) - .Append("builder.WithErrorMetadata(") - .Append(ToStringLiteral(rule.Code)) - .AppendLine(", _ => new OpenApiSchema"); - builder.Append(indent).AppendLine("{"); - builder.Append(indent).AppendLine(" Type = JsonSchemaType.Object,"); - builder - .Append(indent) - .AppendLine(" Properties = new Dictionary(StringComparer.Ordinal)"); - builder.Append(indent).AppendLine(" {"); + var requiredKeys = string.Join( + ", ", + rule.MetadataSchemaProperties.Select(static property => ToStringLiteral(property.Key)) + ); + + writer + .Write("builder.WithErrorMetadata(") + .Write(ToStringLiteral(rule.Code)) + .WriteLine(", _ => new OpenApiSchema") + .WriteLine("{") + .IncreaseIndent() + .WriteLine("Type = JsonSchemaType.Object,") + .WriteLine("Properties = new Dictionary(StringComparer.Ordinal)") + .WriteLine("{") + .IncreaseIndent(); foreach (var property in rule.MetadataSchemaProperties.OrderBy( static property => property.Key, StringComparer.Ordinal )) { - builder - .Append(indent) - .Append(" [") - .Append(ToStringLiteral(property.Key)) - .Append("] = PortableOpenApiSchemaTypeMapper.Map<") - .Append(property.TypeName) - .AppendLine(">(),"); + writer + .Write("[") + .Write(ToStringLiteral(property.Key)) + .Write("] = PortableOpenApiSchemaTypeMapper.Map<") + .Write(property.TypeName) + .WriteLine(">(),"); } - builder.Append(indent).AppendLine(" },"); - builder.Append(indent).Append(" Required = new HashSet(StringComparer.Ordinal) { "); - for (var i = 0; i < rule.MetadataSchemaProperties.Length; i++) - { - if (i > 0) - { - builder.Append(", "); - } - - builder.Append(ToStringLiteral(rule.MetadataSchemaProperties[i].Key)); - } - - builder.AppendLine(" }"); - builder - .Append(indent) - .Append("}, ") - .Append(ToStringLiteral(rule.Code + "Metadata")) - .AppendLine(");"); + return writer + .DecreaseIndent() + .WriteLine("},") + .Write("Required = new HashSet(StringComparer.Ordinal) { ") + .Write(requiredKeys) + .WriteLine(" }") + .DecreaseIndent() + .Write("}, ") + .Write(ToStringLiteral(rule.Code + "Metadata")) + .WriteLine(");"); } - private static void EmitExamples(StringBuilder builder, ValidatorModel model, string indent) + private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) { foreach (var rule in model.Rules) { @@ -204,46 +190,45 @@ private static void EmitExamples(StringBuilder builder, ValidatorModel model, st continue; } - builder.Append(indent) - .Append("builder.WithErrorExample(") - .Append(ToStringLiteral(rule.Code)) - .Append(", ") - .Append(rule.Target is null ? "null" : ToStringLiteral(rule.Target)); + writer + .Write("builder.WithErrorExample(") + .Write(ToStringLiteral(rule.Code)) + .Write(", ") + .Write(rule.Target is null ? "null" : ToStringLiteral(rule.Target)); if (rule.MetadataValues.Length > 0) { - builder.Append(", "); - EmitMetadataDictionary(builder, rule.MetadataValues); + EmitMetadataDictionary(writer.Write(", "), rule.MetadataValues); } - builder.AppendLine(");"); + writer.WriteLine(");"); } if (model.Rules.Length > 0) { - builder.AppendLine(); + writer.WriteLine(); } + + return writer; } - private static void EmitMetadataDictionary(StringBuilder builder, IEnumerable metadataValues) + private static CodeWriter EmitMetadataDictionary( + CodeWriter writer, + IEnumerable metadataValues + ) { - builder.Append("new Dictionary(StringComparer.Ordinal) { "); - var isFirst = true; - foreach (var metadataValue in metadataValues) - { - if (!isFirst) - { - builder.Append(", "); - } - - builder.Append('[') - .Append(ToStringLiteral(metadataValue.Key)) - .Append("] = ") - .Append(ToLiteral(metadataValue.Value)); - isFirst = false; - } + var entries = string.Join( + ", ", + metadataValues.Select( + static metadataValue => + $"[{ToStringLiteral(metadataValue.Key)}] = {ToLiteral(metadataValue.Value)}" + ) + ); - builder.Append(" }"); + return writer + .Write("new Dictionary(StringComparer.Ordinal) { ") + .Write(entries) + .Write(" }"); } private static string? GetTypedHelperName(RuleCallModel rule) => @@ -284,15 +269,13 @@ private static string ToLiteral(object? value) => private static string ToStringLiteral(string value) { - var builder = new StringBuilder(value.Length + 2); - builder.Append('"'); + var builder = new StringBuilder(value.Length + 2).Append('"'); foreach (var c in value) { builder.Append(EscapeChar(c)); } - builder.Append('"'); - return builder.ToString(); + return builder.Append('"').ToString(); } private static string EscapeChar(char value) => From 514b38f863361032aea6c8d697bcd10569169255 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 17 May 2026 21:56:21 +0200 Subject: [PATCH 08/17] chore: add AI plan 43-1 for improving documentation hints Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 3 +- ...md => 0043-0-openapi-source-generation.md} | 0 .../0043-1-improve-documentation-hints.md | 166 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) rename ai-plans/{0043-openapi-source-generation.md => 0043-0-openapi-source-generation.md} (100%) create mode 100644 ai-plans/0043-1-improve-documentation-hints.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index f5b362e..0cf863c 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -56,7 +56,8 @@ - + + diff --git a/ai-plans/0043-openapi-source-generation.md b/ai-plans/0043-0-openapi-source-generation.md similarity index 100% rename from ai-plans/0043-openapi-source-generation.md rename to ai-plans/0043-0-openapi-source-generation.md diff --git a/ai-plans/0043-1-improve-documentation-hints.md b/ai-plans/0043-1-improve-documentation-hints.md new file mode 100644 index 0000000..3584018 --- /dev/null +++ b/ai-plans/0043-1-improve-documentation-hints.md @@ -0,0 +1,166 @@ +# Improve Validation OpenAPI Documentation Hints + +## Rationale + +The first validation OpenAPI source generator can infer metadata from top-level annotated check chains, and it already offers `[PortableValidationOpenApiErrorHint]` for validation flows that are opaque to static analysis. The current hint is intentionally small: it can document an extra error code and optionally a metadata type. That is not enough for guarded rules, `Custom(...)`, `Must(...)`, `ErrorOverrides`, and other imperative validation paths where users still know the exact OpenAPI contract they want to publish. + +This plan improves the explicit documentation hint model before broadening automatic control-flow analysis. The goal is to let users precisely document non-inferable validation errors while keeping the generator deterministic, NativeAOT-safe, and source-generator-friendly. + +## Acceptance Criteria + +- [ ] The validation OpenAPI hint API can document an error code with optional metadata schema information, optional response-example target, and optional response-example metadata values without requiring the generator to interpret arbitrary runtime objects. +- [ ] Existing `[PortableValidationOpenApiErrorHint]` usages remain source-compatible. If new attributes are introduced instead of extending the existing attribute, the old attribute continues to work for code-only and metadata-type hints. +- [ ] Hints can be applied at validator class level and `PerformValidation` method level, matching the current hint scope. +- [ ] Generated code emits the same builder calls that users would write manually: `WithErrorCodes(...)`, `WithErrorMetadata(...)` or equivalent inline metadata schema configuration, and `WithErrorExample(...)` when example data is supplied. +- [ ] The generator validates malformed hints with clear diagnostics when the emitted endpoint schema would be ambiguous or impossible to generate. +- [ ] The generator keeps exhaustive schemas honest: hints add documented error contracts, and `AllowUnknownErrorCodes()` is still emitted only when the user explicitly requests it. +- [ ] The implementation remains incremental-generator-friendly. All hint model values flowing through the pipeline use value equality, deterministic ordering, and source-generator-safe attribute argument shapes. +- [ ] Generated source compiles in consumer projects with implicit usings disabled and nullable enabled, and continues to use fully qualified names or deterministic generated using directives. +- [ ] Automated tests cover code-only hints, metadata-type hints, inline metadata-schema hints, example target hints, example metadata hints, duplicate compatible hints, malformed hints, interaction with opaque-flow diagnostics, and generated OpenAPI document output. +- [ ] Documentation is updated to explain when to use explicit hints instead of `AllowUnknownErrorCodes()`, how hints compose with generated inference, and which validation flows still require manual endpoint builder configuration. + +## Technical Details + +### Scope + +This plan is about explicit documentation hints only. It should not add branch-sensitive or branch-insensitive analysis for `if`, `switch`, loops, lambdas, local functions, or helper methods. Existing nested-check warnings remain useful because they tell the user when a hint may be needed. + +The design should favor a small attribute model over a broad generator configuration system. General endpoint customization should continue to happen through the existing `configure` callback on `ProducesPortableValidationProblemFor(...)`. + +### Attribute Model + +Keep all source-generator-facing hint attributes in `Light.PortableResults.Validation.OpenApi`. Attribute constructor arguments and settable properties must use compiler-supported attribute types only: primitive values, strings, `Type`, enums, and arrays of those values. + +The existing attribute should continue to work: + +```csharp +[PortableValidationOpenApiErrorHint("MovieAlreadyRated")] +[PortableValidationOpenApiErrorHint("MovieAlreadyRated", typeof(MovieAlreadyRatedMetadata))] +``` + +To support richer hints, prefer extending `PortableValidationOpenApiErrorHintAttribute` with optional named properties. The hint model is still small enough that a single attribute is easier to discover, document, and reason about than a family of companion attributes. For example: + +```csharp +[PortableValidationOpenApiErrorHint( + "MovieAlreadyRated", + typeof(MovieAlreadyRatedMetadata), + ExampleTarget = "movieId")] + +[PortableValidationOpenApiErrorHint( + "RatingTooLow", + ExampleTarget = "rating", + ExampleMetadataKey = "minimum", + ExampleMetadataInt64Value = 1)] +``` + +The exact property names can be refined during implementation. The important constraint is that schema documentation and example documentation are both possible without parsing object initializers or invoking runtime code. + +Do not introduce companion attributes for code, schema, or example target configuration unless the implementation shows that the single-attribute model is structurally insufficient. The main case where a companion attribute may still be justified is repeated keyed example metadata for the same error example. If that is needed, use one focused companion attribute for repeated metadata entries rather than parallel arrays on the main hint attribute. + +For example metadata values, support the same small constant family that the current rule metadata path can emit safely: string, integral numeric values, boolean, and `Type` as a string value. Decimal and floating-point values may be added if doing so fits cleanly with the existing emitter literal support. Complex example values are out of scope; users can still add those manually in the endpoint `configure` callback. + +### Schema Hints + +Code-only hints should continue to emit `builder.WithErrorCodes("Code")`. + +Metadata-type hints should continue to emit `builder.WithErrorMetadata("Code")`. + +If inline metadata schema hints are added, they should follow the same conceptual model as `[ValidationErrorMetadataContract]`: a code has a set of required metadata properties, and each property has a CLR type. The generator should emit `builder.WithErrorMetadata("Code", _ => new OpenApiSchema { ... })` using `PortableOpenApiSchemaTypeMapper.Map()`, matching the existing generated code for annotated custom rules. + +Inline metadata schema hints are lower priority than code-only hints, metadata-type hints, example targets, and simple example metadata. If the implementation needs to stage the work, complete the simpler hint forms first and add inline schema hints only after the conflict and diagnostic rules are clear. + +Do not instantiate validation error definitions or metadata objects. The generator should read attributes from symbols and emit builder calls directly. + +### Example Hints + +Example hints should be independent from schema hints. A user may document only the schema, only an example, or both. If an example supplies metadata values, the generated `WithErrorExample(...)` call should include the constant dictionary. If an example supplies no metadata values, it should emit the simpler target-only overload. + +Hints contribute entries to the single generated validation response example that the current OpenAPI bridge composes for an endpoint response. This plan does not introduce multiple named OpenAPI examples per response. + +The generator should not require every metadata schema property to appear in an example. OpenAPI examples are illustrative, not schema definitions. However, if the same hinted example supplies the same metadata key with incompatible values or types, the generator should report a diagnostic instead of choosing one arbitrarily. + +### Conflict Rules + +The generator should deduplicate compatible hints by error code and schema shape. Duplicate code-only hints are harmless. Duplicate metadata-type hints for the same code are compatible only when the metadata type is the same. Inline schema hints for the same code are compatible only when they declare the same metadata keys with the same CLR types. + +Conflicts that would produce ambiguous schema configuration should be errors. Conflicts that only duplicate an example should either be deduplicated deterministically or reported as warnings if deduplication would hide user intent. + +Hints should compose with inferred rules. If an inferred rule and a hint document the same code with the same schema shape, the generated schema should contain one contract. If they document the same code with incompatible schema shapes, the generator should report a diagnostic because the endpoint contract would be ambiguous. + +### Diagnostics + +Add diagnostics for malformed hint usage. The generator should treat hints as a small declarative contract and validate them before emitting code. + +Code validation: + +- Error codes must not be null, empty, or whitespace. +- Error codes should be emitted exactly as supplied. The generator should not trim, case-fold, or otherwise normalize them because the runtime error code contract is string-based. +- Duplicate code-only hints are allowed. +- A code-only hint is compatible with inferred or hinted metadata for the same code because it does not define a competing metadata shape. + +Metadata schema validation: + +- Metadata type hints must not use `typeof(void)`, open generic types, pointer types, by-reference types, function pointer types, or unresolved error symbols. +- The same code cannot be documented with two different CLR metadata types. +- The same code cannot be documented with both a CLR metadata type and an incompatible inline metadata schema. +- Inline metadata property keys must not be null, empty, or whitespace. +- Inline metadata property keys must be unique per code. +- Duplicate inline metadata properties are compatible only when both the key and CLR type match. +- Inline metadata property types should be representable by `PortableOpenApiSchemaTypeMapper`. Unsupported complex types may fall back to an unconstrained `OpenApiSchema` with a warning, but conflicts or unresolved types should be errors. +- Inline schema hints for the same code are compatible only when they declare the same metadata property set with the same CLR types. + +Example validation: + +- Example hint codes must pass the same code validation as schema hints. +- Example targets may be omitted, but supplied targets must not be empty or whitespace. +- Example metadata keys must not be null, empty, or whitespace. +- Example metadata values must specify exactly one supported constant value source. +- Example metadata values must not specify multiple constant sources at the same time, such as both `ConstantStringValue` and `ConstantInt64Value`. +- Example metadata values must specify at least one supported constant source. +- Duplicate example metadata keys for the same code and same example are compatible only when the emitted value and inferred CLR type are identical. +- Example metadata does not need to cover every schema property. +- Example metadata keys that are not present in a documented metadata schema for the same code should produce a warning, not an error, because examples are illustrative and the schema may be supplied manually in the endpoint `configure` callback. +- When an example metadata key matches a documented schema property, the constant value type should be assignable to, or reasonably compatible with, the schema property type. Incompatible values should produce a diagnostic. + +Conflict validation: + +- Hints and inferred rules for the same code must agree on metadata shape. Compatible duplicates are deduplicated; incompatible shapes are errors. +- Hints and inferred typed helper rules must not produce conflicting typed contracts for the same code. +- Class-level and method-level hints for the same contract are compatible duplicates. +- Class-level and method-level hints that document the same code with incompatible metadata contracts are errors, preferably reported at the more specific conflicting attribute. +- Multiple examples for the same code and target should either be deduplicated when identical or diagnosed when they differ. The generator should not silently choose one example arbitrarily. + +Attribute placement validation: + +- The generator only consumes hint attributes from marked validators and the analyzed `PerformValidation` method. +- Hint attributes on a marked validator's other methods should be ignored unless the implementation can confidently report a useful diagnostic without noisy false positives. +- Hint attributes on validators that are not marked with `[GeneratePortableValidationOpenApi]` do not need diagnostics from this generator. + +Generated-code safety validation: + +- Every hinted type name must be emitted fully qualified. +- Every hinted literal must be safe to emit as C# source. +- Hints must not require generated code to use reflection, instantiate arbitrary user types, or read runtime state. + +Diagnostics should point at the attribute syntax when possible. Opaque-flow diagnostics should remain warnings; adding a hint is one supported way to satisfy the documentation gap, but the generator should not try to prove that a hint fully covers the opaque runtime flow. + +### Generated Output + +The emitter should keep deterministic ordering: + +- code-only hints grouped and sorted by code; +- metadata schema hints sorted by code and metadata key; +- examples emitted in source order when that better reflects user intent, or sorted deterministically if source order is not preserved by the model. + +Generated source must keep the existing controlled using block and must not depend on consumer implicit usings, global usings, aliases, or local using directives. + +### Documentation And Examples + +Update the README source-generation section with examples for: + +- adding an error code for `Custom(...)`; +- documenting metadata for an opaque custom path; +- adding a response example target and metadata values; +- choosing between explicit hints and `AllowUnknownErrorCodes()`. + +The documentation should state the intended rule clearly: use explicit hints when the endpoint emits known validation error contracts that the generator cannot infer; use `AllowUnknownErrorCodes()` when the endpoint may emit additional codes that are not enumerable at build time. From 95898c9d16c49fee03c6d85a259a82f642fa09e7 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 17 May 2026 22:23:36 +0200 Subject: [PATCH 09/17] chore: improve AI plan 43-1 for extended hint model Signed-off-by: Kenny Pflug --- .../0043-1-improve-documentation-hints.md | 165 +++++------------- 1 file changed, 43 insertions(+), 122 deletions(-) diff --git a/ai-plans/0043-1-improve-documentation-hints.md b/ai-plans/0043-1-improve-documentation-hints.md index 3584018..31672d4 100644 --- a/ai-plans/0043-1-improve-documentation-hints.md +++ b/ai-plans/0043-1-improve-documentation-hints.md @@ -2,165 +2,86 @@ ## Rationale -The first validation OpenAPI source generator can infer metadata from top-level annotated check chains, and it already offers `[PortableValidationOpenApiErrorHint]` for validation flows that are opaque to static analysis. The current hint is intentionally small: it can document an extra error code and optionally a metadata type. That is not enough for guarded rules, `Custom(...)`, `Must(...)`, `ErrorOverrides`, and other imperative validation paths where users still know the exact OpenAPI contract they want to publish. - -This plan improves the explicit documentation hint model before broadening automatic control-flow analysis. The goal is to let users precisely document non-inferable validation errors while keeping the generator deterministic, NativeAOT-safe, and source-generator-friendly. +The first validation OpenAPI source generator (plan 0043-0) introduced a minimal `[PortableValidationOpenApiErrorHint]` that can document an extra error code with an optional metadata type. That is not enough for guarded rules, `Custom(...)`, `Must(...)`, `ErrorOverrides`, and other imperative paths where users still know exactly which contracts the endpoint publishes. This plan extends the hint model so users can declare schema *and* response-example content for non-inferable validation errors while keeping the generator deterministic, NativeAOT-safe, and incremental-pipeline-friendly. The change is purely additive: every existing hint usage keeps compiling unchanged, nothing is obsoleted, and no migration step is required. ## Acceptance Criteria - [ ] The validation OpenAPI hint API can document an error code with optional metadata schema information, optional response-example target, and optional response-example metadata values without requiring the generator to interpret arbitrary runtime objects. -- [ ] Existing `[PortableValidationOpenApiErrorHint]` usages remain source-compatible. If new attributes are introduced instead of extending the existing attribute, the old attribute continues to work for code-only and metadata-type hints. -- [ ] Hints can be applied at validator class level and `PerformValidation` method level, matching the current hint scope. -- [ ] Generated code emits the same builder calls that users would write manually: `WithErrorCodes(...)`, `WithErrorMetadata(...)` or equivalent inline metadata schema configuration, and `WithErrorExample(...)` when example data is supplied. -- [ ] The generator validates malformed hints with clear diagnostics when the emitted endpoint schema would be ambiguous or impossible to generate. -- [ ] The generator keeps exhaustive schemas honest: hints add documented error contracts, and `AllowUnknownErrorCodes()` is still emitted only when the user explicitly requests it. -- [ ] The implementation remains incremental-generator-friendly. All hint model values flowing through the pipeline use value equality, deterministic ordering, and source-generator-safe attribute argument shapes. -- [ ] Generated source compiles in consumer projects with implicit usings disabled and nullable enabled, and continues to use fully qualified names or deterministic generated using directives. -- [ ] Automated tests cover code-only hints, metadata-type hints, inline metadata-schema hints, example target hints, example metadata hints, duplicate compatible hints, malformed hints, interaction with opaque-flow diagnostics, and generated OpenAPI document output. -- [ ] Documentation is updated to explain when to use explicit hints instead of `AllowUnknownErrorCodes()`, how hints compose with generated inference, and which validation flows still require manual endpoint builder configuration. +- [ ] Existing `[PortableValidationOpenApiErrorHint]` usages remain source-compatible for code-only and metadata-type hints. +- [ ] Hints can be applied at validator class level and `PerformValidation` method level. Both placements feed the same hint pipeline and are subject to the same conflict rules. +- [ ] Generated code emits the same builder calls a user would write manually: `WithErrorCodes(...)`, `WithErrorMetadata(...)` or an inline metadata schema configuration, and `WithErrorExample(...)` when example data is supplied. +- [ ] Hints compose with inferred rules: matching schema shapes are deduplicated; conflicting shapes produce diagnostics; hints never silently weaken an exhaustive schema or trigger `AllowUnknownErrorCodes()` on their own. +- [ ] The implementation remains incremental-generator-friendly: all hint model values flowing through the pipeline use value equality, deterministic ordering, and source-generator-safe attribute argument shapes. +- [ ] Generated source compiles in consumer projects with implicit usings disabled and nullable enabled, and continues to use the existing controlled using block or fully qualified names. +- [ ] Automated tests are written. +- [ ] Documentation is updated to explain when to use explicit hints instead of `AllowUnknownErrorCodes()`, how hints compose with generated inference, and which flows still require the endpoint `configure` callback. ## Technical Details ### Scope -This plan is about explicit documentation hints only. It should not add branch-sensitive or branch-insensitive analysis for `if`, `switch`, loops, lambdas, local functions, or helper methods. Existing nested-check warnings remain useful because they tell the user when a hint may be needed. +This plan covers explicit documentation hints only. It does not add branch-sensitive or branch-insensitive analysis for `if`, `switch`, loops, lambdas, local functions, or helper methods. The existing nested-check warning from 0043-0 remains useful because it tells the user when a hint is needed. General endpoint customization continues to happen through the existing `configure` callback on `ProducesPortableValidationProblemFor(...)`. -The design should favor a small attribute model over a broad generator configuration system. General endpoint customization should continue to happen through the existing `configure` callback on `ProducesPortableValidationProblemFor(...)`. +Conceptually, hint attributes are the user-facing counterpart to the generator-facing `[ValidationRule]` / `[ValidationRuleMetadata]` attributes from 0043-0: same model (a code, a metadata shape, optional constant values), applied at the validator instead of the check method. Reusing that mental model keeps the public surface coherent and lets the emitter share infrastructure for code grouping, schema deduplication, and example composition. ### Attribute Model -Keep all source-generator-facing hint attributes in `Light.PortableResults.Validation.OpenApi`. Attribute constructor arguments and settable properties must use compiler-supported attribute types only: primitive values, strings, `Type`, enums, and arrays of those values. +All hint attributes live in `Light.PortableResults.Validation.OpenApi`. Attribute constructor arguments and settable properties use compiler-supported types only: primitive values, strings, `Type`, enums, and arrays of those. -The existing attribute should continue to work: +Schema hints and example hints are split into two attributes because they describe orthogonal concerns. A user may document only a schema, only an example, or both: -```csharp -[PortableValidationOpenApiErrorHint("MovieAlreadyRated")] -[PortableValidationOpenApiErrorHint("MovieAlreadyRated", typeof(MovieAlreadyRatedMetadata))] -``` +- **`[PortableValidationOpenApiErrorHint]`** — declares "this code is part of the endpoint's contract" and optionally its metadata shape. The existing single-arg and `(code, typeof(TMetadata))` forms continue to work; a new optional property accepts an inline metadata-property set via a paired companion attribute (see below) when neither a code-only nor a typed-metadata hint fits. +- **`[PortableValidationOpenApiExampleHint]`** — declares one entry in the validator-level response example for a given code, with an optional target and zero or more metadata key/value pairs. This attribute does not declare a schema. As a deliberate ergonomic shortcut, if a code appears only in an example hint, the generator treats the schema as `code-only` exactly as if the user had written `[PortableValidationOpenApiErrorHint("Code")]`. This removes a redundant declaration for the common opaque-`Custom(...)` case where the user wants to document the code and ship a sample body in a single place. -To support richer hints, prefer extending `PortableValidationOpenApiErrorHintAttribute` with optional named properties. The hint model is still small enough that a single attribute is easier to discover, document, and reason about than a family of companion attributes. For example: +Because attribute properties cannot hold arbitrary key/value collections, repeated metadata entries are modeled with a small companion attribute keyed by the parent example's code. Multi-key examples — the headline case for built-in codes like `InRange` whose example needs both `lowerBoundary` and `upperBoundary` — are first-class, not a fallback: ```csharp -[PortableValidationOpenApiErrorHint( - "MovieAlreadyRated", - typeof(MovieAlreadyRatedMetadata), - ExampleTarget = "movieId")] - -[PortableValidationOpenApiErrorHint( - "RatingTooLow", - ExampleTarget = "rating", - ExampleMetadataKey = "minimum", - ExampleMetadataInt64Value = 1)] -``` - -The exact property names can be refined during implementation. The important constraint is that schema documentation and example documentation are both possible without parsing object initializers or invoking runtime code. - -Do not introduce companion attributes for code, schema, or example target configuration unless the implementation shows that the single-attribute model is structurally insufficient. The main case where a companion attribute may still be justified is repeated keyed example metadata for the same error example. If that is needed, use one focused companion attribute for repeated metadata entries rather than parallel arrays on the main hint attribute. +[PortableValidationOpenApiErrorHint("RatingTooLow")] -For example metadata values, support the same small constant family that the current rule metadata path can emit safely: string, integral numeric values, boolean, and `Type` as a string value. Decimal and floating-point values may be added if doing so fits cleanly with the existing emitter literal support. Complex example values are out of scope; users can still add those manually in the endpoint `configure` callback. +[PortableValidationOpenApiExampleHint("RatingTooLow", Target = "rating")] +[PortableValidationOpenApiExampleMetadata("RatingTooLow", "lowerBoundary", 1)] +[PortableValidationOpenApiExampleMetadata("RatingTooLow", "upperBoundary", 5)] -### Schema Hints - -Code-only hints should continue to emit `builder.WithErrorCodes("Code")`. - -Metadata-type hints should continue to emit `builder.WithErrorMetadata("Code")`. - -If inline metadata schema hints are added, they should follow the same conceptual model as `[ValidationErrorMetadataContract]`: a code has a set of required metadata properties, and each property has a CLR type. The generator should emit `builder.WithErrorMetadata("Code", _ => new OpenApiSchema { ... })` using `PortableOpenApiSchemaTypeMapper.Map()`, matching the existing generated code for annotated custom rules. +[PortableValidationOpenApiErrorHint("MovieAlreadyRated", typeof(MovieAlreadyRatedMetadata))] +[PortableValidationOpenApiExampleHint("MovieAlreadyRated", Target = "movieId")] +``` -Inline metadata schema hints are lower priority than code-only hints, metadata-type hints, example targets, and simple example metadata. If the implementation needs to stage the work, complete the simpler hint forms first and add inline schema hints only after the conflict and diagnostic rules are clear. +The companion's `code` argument matches the parent example hint's code; this keeps repeated metadata declarations associative without parallel arrays on the main attribute or any object initializer interpretation. To keep the association unambiguous, **a given scope (validator class or `PerformValidation` method) declares at most one example hint per code**. Examples are illustrative, and one canonical body per code per validator is sufficient; users who need multiple targets for the same code on one endpoint can supply them through the `configure` callback. Under that constraint, every `[PortableValidationOpenApiExampleMetadata]` matches exactly one example hint by `(code, scope)`; a companion attribute whose code does not match any example hint in its scope is an error. The implementer should pick whatever overload set on `PortableValidationOpenApiExampleMetadataAttribute` covers string, integral, boolean, and `Type`-as-string constants ergonomically; an `object` parameter is acceptable as a single overload if Roslyn round-trips the constant cleanly, but typed overloads are preferred because they preserve the intended literal shape (e.g. `1L` vs `1`) for the emitter. Decimal and floating-point values may be added if they fit cleanly with existing emitter literal support. Complex values stay out of scope and remain a `configure`-callback concern. -Do not instantiate validation error definitions or metadata objects. The generator should read attributes from symbols and emit builder calls directly. +Inline metadata schema hints, when present, also use a small companion attribute (`[PortableValidationOpenApiErrorMetadataProperty(code, key, typeof(T))]`) for the same reason. They are lower priority than the other hint forms; if staging is needed, ship code-only hints, typed-metadata hints, and example hints first. -### Example Hints +### Generated Output -Example hints should be independent from schema hints. A user may document only the schema, only an example, or both. If an example supplies metadata values, the generated `WithErrorExample(...)` call should include the constant dictionary. If an example supplies no metadata values, it should emit the simpler target-only overload. +The emitter reuses the existing infrastructure built for inferred rules: code-only hints are folded into existing `WithErrorCodes(...)` groupings; typed-metadata hints emit `WithErrorMetadata(...)`; inline schema hints emit `WithErrorMetadata("code", _ => new OpenApiSchema { ... })` through `PortableOpenApiSchemaTypeMapper.Map()`. Example hints emit `WithErrorExample(...)` calls — the simpler target-only overload when no metadata is supplied, and the dictionary overload otherwise. -Hints contribute entries to the single generated validation response example that the current OpenAPI bridge composes for an endpoint response. This plan does not introduce multiple named OpenAPI examples per response. +Ordering is deterministic: schema calls grouped and sorted by code; metadata properties sorted by key; example entries emitted in a stable order derived from source position or a sort on `(code, target)`. Generated source continues to use the controlled using block from 0043-0 and must not rely on consumer implicit usings, global usings, aliases, or local using directives. -The generator should not require every metadata schema property to appear in an example. OpenAPI examples are illustrative, not schema definitions. However, if the same hinted example supplies the same metadata key with incompatible values or types, the generator should report a diagnostic instead of choosing one arbitrarily. +The generator does not instantiate validation error definitions or metadata objects at any point; everything flows from attribute symbols straight to builder calls, preserving the NativeAOT-safety guarantee from 0043-0. -### Conflict Rules +### Hint Scope and Placement -The generator should deduplicate compatible hints by error code and schema shape. Duplicate code-only hints are harmless. Duplicate metadata-type hints for the same code are compatible only when the metadata type is the same. Inline schema hints for the same code are compatible only when they declare the same metadata keys with the same CLR types. +Hint attributes are consumed only from validators marked with `[GeneratePortableValidationOpenApi]` and from their `PerformValidation` method. Class-level and method-level hints feed the same pipeline; the distinction is documentation locality and diagnostic anchoring, not semantics. Hints on other members of a marked validator are ignored without diagnostic to avoid noisy false positives. Hints on unmarked validators are not analyzed. Base-class hints are not chased, matching the analysis boundary established in 0043-0. -Conflicts that would produce ambiguous schema configuration should be errors. Conflicts that only duplicate an example should either be deduplicated deterministically or reported as warnings if deduplication would hide user intent. +### Composition with `AllowUnknownErrorCodes` -Hints should compose with inferred rules. If an inferred rule and a hint document the same code with the same schema shape, the generated schema should contain one contract. If they document the same code with incompatible schema shapes, the generator should report a diagnostic because the endpoint contract would be ambiguous. +Hints and `AllowUnknownErrorCodes()` are complementary, not alternatives. A validator may declare hints for the codes the endpoint *does* publish and additionally opt into `AllowUnknownErrorCodes()` for codes that are not enumerable at build time. The generator emits `AllowUnknownErrorCodes()` only when the user explicitly requested it via the existing opt-in attribute; declaring hints never triggers it implicitly. ### Diagnostics -Add diagnostics for malformed hint usage. The generator should treat hints as a small declarative contract and validate them before emitting code. - -Code validation: - -- Error codes must not be null, empty, or whitespace. -- Error codes should be emitted exactly as supplied. The generator should not trim, case-fold, or otherwise normalize them because the runtime error code contract is string-based. -- Duplicate code-only hints are allowed. -- A code-only hint is compatible with inferred or hinted metadata for the same code because it does not define a competing metadata shape. - -Metadata schema validation: - -- Metadata type hints must not use `typeof(void)`, open generic types, pointer types, by-reference types, function pointer types, or unresolved error symbols. -- The same code cannot be documented with two different CLR metadata types. -- The same code cannot be documented with both a CLR metadata type and an incompatible inline metadata schema. -- Inline metadata property keys must not be null, empty, or whitespace. -- Inline metadata property keys must be unique per code. -- Duplicate inline metadata properties are compatible only when both the key and CLR type match. -- Inline metadata property types should be representable by `PortableOpenApiSchemaTypeMapper`. Unsupported complex types may fall back to an unconstrained `OpenApiSchema` with a warning, but conflicts or unresolved types should be errors. -- Inline schema hints for the same code are compatible only when they declare the same metadata property set with the same CLR types. - -Example validation: - -- Example hint codes must pass the same code validation as schema hints. -- Example targets may be omitted, but supplied targets must not be empty or whitespace. -- Example metadata keys must not be null, empty, or whitespace. -- Example metadata values must specify exactly one supported constant value source. -- Example metadata values must not specify multiple constant sources at the same time, such as both `ConstantStringValue` and `ConstantInt64Value`. -- Example metadata values must specify at least one supported constant source. -- Duplicate example metadata keys for the same code and same example are compatible only when the emitted value and inferred CLR type are identical. -- Example metadata does not need to cover every schema property. -- Example metadata keys that are not present in a documented metadata schema for the same code should produce a warning, not an error, because examples are illustrative and the schema may be supplied manually in the endpoint `configure` callback. -- When an example metadata key matches a documented schema property, the constant value type should be assignable to, or reasonably compatible with, the schema property type. Incompatible values should produce a diagnostic. - -Conflict validation: - -- Hints and inferred rules for the same code must agree on metadata shape. Compatible duplicates are deduplicated; incompatible shapes are errors. -- Hints and inferred typed helper rules must not produce conflicting typed contracts for the same code. -- Class-level and method-level hints for the same contract are compatible duplicates. -- Class-level and method-level hints that document the same code with incompatible metadata contracts are errors, preferably reported at the more specific conflicting attribute. -- Multiple examples for the same code and target should either be deduplicated when identical or diagnosed when they differ. The generator should not silently choose one example arbitrarily. - -Attribute placement validation: - -- The generator only consumes hint attributes from marked validators and the analyzed `PerformValidation` method. -- Hint attributes on a marked validator's other methods should be ignored unless the implementation can confidently report a useful diagnostic without noisy false positives. -- Hint attributes on validators that are not marked with `[GeneratePortableValidationOpenApi]` do not need diagnostics from this generator. - -Generated-code safety validation: - -- Every hinted type name must be emitted fully qualified. -- Every hinted literal must be safe to emit as C# source. -- Hints must not require generated code to use reflection, instantiate arbitrary user types, or read runtime state. - -Diagnostics should point at the attribute syntax when possible. Opaque-flow diagnostics should remain warnings; adding a hint is one supported way to satisfy the documentation gap, but the generator should not try to prove that a hint fully covers the opaque runtime flow. - -### Generated Output +The diagnostic philosophy mirrors 0043-0: -The emitter should keep deterministic ordering: +- **Errors** for malformed hints (empty/whitespace codes or keys, `typeof(void)` and other unrepresentable metadata types, unresolved error symbols), for conflicting contracts that would make the emitted schema ambiguous (same code with two different metadata types, same code with metadata-type and incompatible inline-schema, class-level and method-level hints declaring incompatible shapes for the same code, inferred-rule vs. hint disagreement on metadata shape), for example-hint structural violations (more than one example hint per code per scope), and for orphan companion attributes — any `[PortableValidationOpenApiExampleMetadata]` or `[PortableValidationOpenApiErrorMetadataProperty]` whose code does not match a parent hint in its scope. +- **Warnings** for illustrative-only mismatches that do not corrupt the contract: an example metadata key that does not appear in any documented schema for that code; an inline metadata property using a complex type that falls back to an unconstrained `OpenApiSchema`. +- **No diagnostic** for compatible duplicates — same code with identical metadata shape, identical example bodies — which are deduplicated silently. Deduplication is by `(code, schema shape)` for schema hints and by `(code, target, metadata)` for example hints. -- code-only hints grouped and sorted by code; -- metadata schema hints sorted by code and metadata key; -- examples emitted in source order when that better reflects user intent, or sorted deterministically if source order is not preserved by the model. +Existing opaque-flow warnings from 0043-0 remain warnings; adding a hint is one supported way to satisfy the documentation gap, but the generator does not attempt to prove a hint fully covers the runtime flow. -Generated source must keep the existing controlled using block and must not depend on consumer implicit usings, global usings, aliases, or local using directives. +Diagnostic IDs continue in the `LPRSG` range and point at the most specific attribute syntax available — the conflicting attribute, not the validator declaration. -### Documentation And Examples +### Tests -Update the README source-generation section with examples for: +In addition to the standard generator/snapshot coverage already established by 0043-0, the hint-specific test surface should at minimum exercise: code-only, metadata-type, and inline-schema hints; example hints with and without metadata; multi-key example metadata; class-level vs. method-level placement; duplicate-compatible deduplication; each error and warning diagnostic; interaction with inferred rules for the same code; interaction with `AllowUnknownErrorCodes()`; and end-to-end OpenAPI document output for a sample validator that mixes inferred rules with hinted opaque calls. -- adding an error code for `Custom(...)`; -- documenting metadata for an opaque custom path; -- adding a response example target and metadata values; -- choosing between explicit hints and `AllowUnknownErrorCodes()`. +### Documentation and Examples -The documentation should state the intended rule clearly: use explicit hints when the endpoint emits known validation error contracts that the generator cannot infer; use `AllowUnknownErrorCodes()` when the endpoint may emit additional codes that are not enumerable at build time. +Update the README source-generation section with worked examples for: adding an error code for `Custom(...)`; documenting metadata for an opaque custom path; adding a response-example target and metadata values, including a multi-key example; choosing between explicit hints and `AllowUnknownErrorCodes()`. The guiding rule to state plainly: use explicit hints when the endpoint emits known validation error contracts the generator cannot infer; use `AllowUnknownErrorCodes()` when the endpoint may emit additional codes that are not enumerable at build time. The two compose. From 573bcb9b14c08f5172e07d2250cb6cff13f47652 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Mon, 18 May 2026 06:43:58 +0200 Subject: [PATCH 10/17] feat: initial implementation of OpenAPI hint extension Signed-off-by: Kenny Pflug --- README.md | 33 ++ .../0043-1-improve-documentation-hints.md | 18 +- .../DiagnosticDescriptors.cs | 31 ++ .../KnownTypeNames.cs | 6 + .../ValidatorOpenApiAnalyzer.cs | 381 +++++++++++++++++- .../ValidatorOpenApiEmitter.cs | 104 ++++- .../ValidatorOpenApiModels.cs | 31 +- ...onOpenApiErrorMetadataPropertyAttribute.cs | 39 ++ ...leValidationOpenApiExampleHintAttribute.cs | 29 ++ ...lidationOpenApiExampleMetadataAttribute.cs | 98 +++++ ...PortableValidationOpenApiGeneratorTests.cs | 167 +++++++- 11 files changed, 896 insertions(+), 41 deletions(-) create mode 100644 src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorMetadataPropertyAttribute.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleMetadataAttribute.cs diff --git a/README.md b/README.md index 7fa72bb..4d8263e 100644 --- a/README.md +++ b/README.md @@ -1467,6 +1467,39 @@ The generator analyzes top-level `context.Check(...).Rule(...)` chains in synchr When metadata arguments are compile-time constants, such as `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`, the generated contract also contributes a response-level OpenAPI example. Scalar and Swagger UI show these concrete values in the validation problem example body. Non-constant metadata arguments still get documented schemas, but that specific call site is omitted from the example. Delegate-based `Must(...)`, `Custom(...)`, `ErrorOverrides`, async validators, source-null errors emitted before `PerformValidation`, child validators, and complex target inference are intentionally outside the first-generation analysis. +Use explicit hints when the validator emits known validation error contracts that the generator cannot infer. For an opaque `Custom(...)` path that only needs a code in the schema, add a code-only hint at the validator or `PerformValidation` method level: + +```csharp +[GeneratePortableValidationOpenApi] +[PortableValidationOpenApiErrorHint("MovieAlreadyRated")] +public sealed partial class AddMovieRatingValidator : Validator +{ + // ... +} +``` + +If the opaque path has endpoint-specific metadata, either point to a metadata type or declare the metadata schema inline: + +```csharp +[PortableValidationOpenApiErrorHint("MovieAlreadyRated", typeof(MovieAlreadyRatedMetadata))] + +[PortableValidationOpenApiErrorHint("RatingTooLow")] +[PortableValidationOpenApiErrorMetadataProperty("RatingTooLow", "lowerBoundary", typeof(int))] +[PortableValidationOpenApiErrorMetadataProperty("RatingTooLow", "upperBoundary", typeof(int))] +``` + +Hints compose with inferred rules. Matching schema shapes are deduplicated, while conflicting metadata shapes for the same code are reported by the generator because the emitted OpenAPI contract would otherwise be ambiguous. Hinting a code never makes the generated response non-exhaustive and never calls `AllowUnknownErrorCodes()` by itself. + +You can also provide response-example entries for opaque paths. An example-only hint documents the code as code-only, so the common case does not need a separate `[PortableValidationOpenApiErrorHint]`. Metadata values are compile-time constants declared with companion attributes: + +```csharp +[PortableValidationOpenApiExampleHint("RatingTooLow", Target = "rating")] +[PortableValidationOpenApiExampleMetadata("RatingTooLow", "lowerBoundary", 1)] +[PortableValidationOpenApiExampleMetadata("RatingTooLow", "upperBoundary", 5)] +``` + +Use `AllowUnknownErrorCodes()` only when the endpoint may emit additional codes that are not enumerable at build time. Explicit hints and `AllowUnknownErrorCodes()` compose: hints document the known contract, and the unknown-code opt-in keeps the generated schema non-exhaustive for the rest. Endpoint-level customization that is not validator-local, such as changing the validation problem format, documenting multiple example targets for the same code, or adding contracts decided outside the validator, still belongs in the endpoint `configure` callback. + Register reusable per-error-code metadata contracts once in DI by passing them to `AddPortableResultsOpenApi(...)`: ```csharp diff --git a/ai-plans/0043-1-improve-documentation-hints.md b/ai-plans/0043-1-improve-documentation-hints.md index 31672d4..baa4e0b 100644 --- a/ai-plans/0043-1-improve-documentation-hints.md +++ b/ai-plans/0043-1-improve-documentation-hints.md @@ -6,15 +6,15 @@ The first validation OpenAPI source generator (plan 0043-0) introduced a minimal ## Acceptance Criteria -- [ ] The validation OpenAPI hint API can document an error code with optional metadata schema information, optional response-example target, and optional response-example metadata values without requiring the generator to interpret arbitrary runtime objects. -- [ ] Existing `[PortableValidationOpenApiErrorHint]` usages remain source-compatible for code-only and metadata-type hints. -- [ ] Hints can be applied at validator class level and `PerformValidation` method level. Both placements feed the same hint pipeline and are subject to the same conflict rules. -- [ ] Generated code emits the same builder calls a user would write manually: `WithErrorCodes(...)`, `WithErrorMetadata(...)` or an inline metadata schema configuration, and `WithErrorExample(...)` when example data is supplied. -- [ ] Hints compose with inferred rules: matching schema shapes are deduplicated; conflicting shapes produce diagnostics; hints never silently weaken an exhaustive schema or trigger `AllowUnknownErrorCodes()` on their own. -- [ ] The implementation remains incremental-generator-friendly: all hint model values flowing through the pipeline use value equality, deterministic ordering, and source-generator-safe attribute argument shapes. -- [ ] Generated source compiles in consumer projects with implicit usings disabled and nullable enabled, and continues to use the existing controlled using block or fully qualified names. -- [ ] Automated tests are written. -- [ ] Documentation is updated to explain when to use explicit hints instead of `AllowUnknownErrorCodes()`, how hints compose with generated inference, and which flows still require the endpoint `configure` callback. +- [x] The validation OpenAPI hint API can document an error code with optional metadata schema information, optional response-example target, and optional response-example metadata values without requiring the generator to interpret arbitrary runtime objects. +- [x] Existing `[PortableValidationOpenApiErrorHint]` usages remain source-compatible for code-only and metadata-type hints. +- [x] Hints can be applied at validator class level and `PerformValidation` method level. Both placements feed the same hint pipeline and are subject to the same conflict rules. +- [x] Generated code emits the same builder calls a user would write manually: `WithErrorCodes(...)`, `WithErrorMetadata(...)` or an inline metadata schema configuration, and `WithErrorExample(...)` when example data is supplied. +- [x] Hints compose with inferred rules: matching schema shapes are deduplicated; conflicting shapes produce diagnostics; hints never silently weaken an exhaustive schema or trigger `AllowUnknownErrorCodes()` on their own. +- [x] The implementation remains incremental-generator-friendly: all hint model values flowing through the pipeline use value equality, deterministic ordering, and source-generator-safe attribute argument shapes. +- [x] Generated source compiles in consumer projects with implicit usings disabled and nullable enabled, and continues to use the existing controlled using block or fully qualified names. +- [x] Automated tests are written. +- [x] Documentation is updated to explain when to use explicit hints instead of `AllowUnknownErrorCodes()`, how hints compose with generated inference, and which flows still require the endpoint `configure` callback. ## Technical Details diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs index 874c7f2..2ab01e1 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs @@ -5,6 +5,7 @@ namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; internal static class DiagnosticDescriptors { private const string Category = "Light.PortableResults.Validation.OpenApi.SourceGeneration"; + private const string HelpLinkBase = "https://github.com/feO2x/Light.PortableResults/blob/main/README.md#validation-openapi-source-generation"; @@ -97,4 +98,34 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true, helpLinkUri: HelpLinkBase ); + + public static readonly DiagnosticDescriptor InvalidHint = new ( + "LPRSG0010", + "Validation OpenAPI hint is malformed", + "Validation OpenAPI hint is malformed: {0}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor ConflictingHint = new ( + "LPRSG0011", + "Validation OpenAPI hint conflicts with another contract", + "Validation OpenAPI hint for code '{0}' conflicts with another documented metadata contract", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor ExampleMetadataWithoutSchema = new ( + "LPRSG0012", + "Validation OpenAPI example metadata is not declared by a schema hint", + "Validation OpenAPI example metadata key '{1}' for code '{0}' is not declared by any inline metadata schema for that code", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs index e29bd07..f90b59d 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs @@ -6,6 +6,12 @@ internal static class KnownTypeNames "Light.PortableResults.Validation.OpenApi.GeneratePortableValidationOpenApiAttribute"; public const string ErrorHintAttribute = "Light.PortableResults.Validation.OpenApi.PortableValidationOpenApiErrorHintAttribute"; + public const string ExampleHintAttribute = + "Light.PortableResults.Validation.OpenApi.PortableValidationOpenApiExampleHintAttribute"; + public const string ExampleMetadataAttribute = + "Light.PortableResults.Validation.OpenApi.PortableValidationOpenApiExampleMetadataAttribute"; + public const string ErrorMetadataPropertyAttribute = + "Light.PortableResults.Validation.OpenApi.PortableValidationOpenApiErrorMetadataPropertyAttribute"; public const string ValidationRuleAttribute = "Light.PortableResults.Validation.Definitions.ValidationRuleAttribute"; public const string ValidationRuleMetadataAttribute = diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs index 8e558d4..168cc0c 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -82,9 +82,11 @@ out var methodDeclaration cancellationToken ); - var hints = GetErrorHints(validatorType, performValidation).ToImmutableArray(); + var hints = GetErrorHints(validatorType, performValidation, diagnostics).ToImmutableArray(); + var examples = GetExampleHints(validatorType, performValidation, diagnostics).ToImmutableArray(); + ValidateHintContracts(rules, hints, examples, diagnostics); var allowUnknownErrorCodes = GetAllowUnknownErrorCodes(validatorType); - if (rules.Count == 0 && hints.Length == 0 && !allowUnknownErrorCodes) + if (rules.Count == 0 && hints.Length == 0 && examples.Length == 0 && !allowUnknownErrorCodes) { diagnostics.Add( Diagnostic.Create( @@ -105,7 +107,8 @@ out var methodDeclaration validatorType.Name, allowUnknownErrorCodes, rules.ToImmutable(), - hints + hints, + examples ); var source = ValidatorOpenApiEmitter.Emit(model); return new ValidatorOpenApiAnalysis(hintName, source, diagnostics.ToImmutable()); @@ -784,23 +787,48 @@ private static bool IsAttribute(AttributeData attribute, string metadataName) private static IEnumerable GetErrorHints( INamedTypeSymbol validatorType, - IMethodSymbol performValidation + IMethodSymbol performValidation, + ICollection diagnostics ) { - foreach (var hint in GetErrorHints(validatorType.GetAttributes())) + foreach (var hint in GetErrorHints(validatorType.GetAttributes(), diagnostics)) { yield return hint; } - foreach (var hint in GetErrorHints(performValidation.GetAttributes())) + foreach (var hint in GetErrorHints(performValidation.GetAttributes(), diagnostics)) { yield return hint; } } - private static IEnumerable GetErrorHints(IEnumerable attributes) + private static IEnumerable GetErrorHints( + IEnumerable attributes, + ICollection diagnostics + ) { - foreach (var attribute in attributes) + var attributeArray = attributes.ToArray(); + var metadataProperties = attributeArray + .Where(static attribute => IsAttribute(attribute, KnownTypeNames.ErrorMetadataPropertyAttribute)) + .Select( + static attribute => new + { + Attribute = attribute, + Code = attribute.ConstructorArguments.Length > 0 ? + attribute.ConstructorArguments[0].Value as string : + null + } + ) + .Where(static item => !string.IsNullOrWhiteSpace(item.Code)) + .GroupBy(static item => item.Code!, StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + group => group.Select(static item => item.Attribute).ToArray(), + StringComparer.Ordinal + ); + var parentCodes = new HashSet(StringComparer.Ordinal); + + foreach (var attribute in attributeArray) { if (!IsAttribute(attribute, KnownTypeNames.ErrorHintAttribute) || attribute.ConstructorArguments.Length == 0 || @@ -809,14 +837,349 @@ private static IEnumerable GetErrorHints(IEnumerable 1 && attribute.ConstructorArguments[1].Value is ITypeSymbol metadataType ? metadataType.ToDisplayString(FullyQualifiedTypeFormat) : null; - yield return new ErrorHintModel(code, metadataTypeName); + if (metadataTypeName == "void" || metadataTypeName == "global::System.Void") + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(attribute), + "metadata type must not be void" + ) + ); + continue; + } + + var properties = GetInlineHintMetadataProperties( + metadataProperties.TryGetValue(code, out var matchedProperties) ? matchedProperties : [], + diagnostics + ); + yield return new ErrorHintModel(code, metadataTypeName, properties); + } + + foreach (var metadataProperty in attributeArray.Where( + static attribute => IsAttribute(attribute, KnownTypeNames.ErrorMetadataPropertyAttribute) + )) + { + var code = metadataProperty.ConstructorArguments.Length > 0 ? + metadataProperty.ConstructorArguments[0].Value as string : + null; + if (string.IsNullOrWhiteSpace(code) || parentCodes.Contains(code!)) + { + continue; + } + + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(metadataProperty), + $"inline metadata property for code '{code}' has no matching error hint in the same scope" + ) + ); } } + private static ImmutableArray GetInlineHintMetadataProperties( + IEnumerable attributes, + ICollection diagnostics + ) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attribute in attributes) + { + if (attribute.ConstructorArguments.Length < 3 || + attribute.ConstructorArguments[1].Value is not string key || + attribute.ConstructorArguments[2].Value is not ITypeSymbol type) + { + continue; + } + + if (string.IsNullOrWhiteSpace(key)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(attribute), + "metadata key must not be empty" + ) + ); + continue; + } + + var typeName = type.ToDisplayString(FullyQualifiedTypeFormat); + if (typeName == "void" || typeName == "global::System.Void") + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(attribute), + "metadata property type must not be void" + ) + ); + continue; + } + + builder.Add(new MetadataSchemaPropertyModel(key, typeName)); + } + + return builder + .GroupBy(static property => property.Key, StringComparer.Ordinal) + .Select(static group => group.First()) + .OrderBy(static property => property.Key, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static IEnumerable GetExampleHints( + INamedTypeSymbol validatorType, + IMethodSymbol performValidation, + ICollection diagnostics + ) + { + foreach (var hint in GetExampleHints(validatorType.GetAttributes(), diagnostics)) + { + yield return hint; + } + + foreach (var hint in GetExampleHints(performValidation.GetAttributes(), diagnostics)) + { + yield return hint; + } + } + + private static IEnumerable GetExampleHints( + IEnumerable attributes, + ICollection diagnostics + ) + { + var attributeArray = attributes.ToArray(); + var parentCodes = new HashSet(StringComparer.Ordinal); + var duplicates = new HashSet(StringComparer.Ordinal); + foreach (var attribute in attributeArray.Where( + static attribute => IsAttribute(attribute, KnownTypeNames.ExampleHintAttribute) + )) + { + var code = attribute.ConstructorArguments.Length > 0 ? + attribute.ConstructorArguments[0].Value as string : + null; + if (string.IsNullOrWhiteSpace(code)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(attribute), + "example hint code must not be empty" + ) + ); + continue; + } + + if (!parentCodes.Add(code!)) + { + duplicates.Add(code!); + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(attribute), + $"more than one example hint for code '{code}' is declared in the same scope" + ) + ); + continue; + } + + var target = attribute.NamedArguments.FirstOrDefault(static argument => argument.Key == "Target") + .Value.Value as string; + var metadataValues = GetExampleMetadataValues(attributeArray, code!, diagnostics); + yield return new ExampleHintModel(code!, target, metadataValues); + } + + foreach (var metadataAttribute in attributeArray.Where( + static attribute => IsAttribute(attribute, KnownTypeNames.ExampleMetadataAttribute) + )) + { + var code = metadataAttribute.ConstructorArguments.Length > 0 ? + metadataAttribute.ConstructorArguments[0].Value as string : + null; + if (string.IsNullOrWhiteSpace(code) || parentCodes.Contains(code!) || duplicates.Contains(code!)) + { + continue; + } + + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(metadataAttribute), + $"example metadata for code '{code}' has no matching example hint in the same scope" + ) + ); + } + } + + private static ImmutableArray GetExampleMetadataValues( + IEnumerable attributes, + string code, + ICollection diagnostics + ) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attribute in attributes.Where( + attribute => IsAttribute(attribute, KnownTypeNames.ExampleMetadataAttribute) && + attribute.ConstructorArguments.Length >= 3 && + attribute.ConstructorArguments[0].Value as string == code + )) + { + if (attribute.ConstructorArguments[1].Value is not string key || + string.IsNullOrWhiteSpace(key)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.InvalidHint, + GetAttributeLocation(attribute), + "example metadata key must not be empty" + ) + ); + continue; + } + + var argument = attribute.ConstructorArguments[2]; + var value = argument.Value is ITypeSymbol typeSymbol ? + typeSymbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat) : + argument.Value; + var typeName = argument.Value is ITypeSymbol ? + "global::System.String" : + argument.Type?.ToDisplayString(FullyQualifiedTypeFormat) ?? "object"; + builder.Add(new MetadataValueModel(key, value, true, typeName)); + } + + return builder + .OrderBy(static metadata => metadata.Key, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static void ValidateHintContracts( + IEnumerable rules, + ImmutableArray hints, + ImmutableArray examples, + ICollection diagnostics + ) + { + var typedHints = new Dictionary(StringComparer.Ordinal); + var inlineHints = new Dictionary>(StringComparer.Ordinal); + foreach (var hint in hints) + { + if (hint.MetadataTypeName is not null) + { + if (typedHints.TryGetValue(hint.Code, out var existingType) && + existingType != hint.MetadataTypeName) + { + diagnostics.Add( + Diagnostic.Create(DiagnosticDescriptors.ConflictingHint, Location.None, hint.Code) + ); + } + + typedHints[hint.Code] = hint.MetadataTypeName; + } + + if (hint.MetadataSchemaProperties.Length > 0) + { + if (hint.MetadataTypeName is not null) + { + diagnostics.Add( + Diagnostic.Create(DiagnosticDescriptors.ConflictingHint, Location.None, hint.Code) + ); + } + + if (inlineHints.TryGetValue(hint.Code, out var existingProperties) && + !MetadataSchemaPropertiesEqual(existingProperties, hint.MetadataSchemaProperties)) + { + diagnostics.Add( + Diagnostic.Create(DiagnosticDescriptors.ConflictingHint, Location.None, hint.Code) + ); + } + + inlineHints[hint.Code] = hint.MetadataSchemaProperties; + } + } + + foreach (var rule in rules.Where(static rule => rule.MetadataSchemaProperties.Length > 0)) + { + if (inlineHints.TryGetValue(rule.Code, out var hintProperties) && + !MetadataSchemaPropertiesEqual(hintProperties, rule.MetadataSchemaProperties)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.ConflictingHint, Location.None, rule.Code)); + } + } + + foreach (var example in examples) + { + if (!inlineHints.TryGetValue(example.Code, out var schemaProperties)) + { + continue; + } + + var schemaKeys = new HashSet( + schemaProperties.Select(static property => property.Key), + StringComparer.Ordinal + ); + foreach (var metadataValue in example.MetadataValues) + { + if (!schemaKeys.Contains(metadataValue.Key)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.ExampleMetadataWithoutSchema, + Location.None, + example.Code, + metadataValue.Key + ) + ); + } + } + } + } + + private static bool MetadataSchemaPropertiesEqual( + ImmutableArray left, + ImmutableArray right + ) + { + if (left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (left[i].Key != right[i].Key || left[i].TypeName != right[i].TypeName) + { + return false; + } + } + + return true; + } + + private static Location GetAttributeLocation(AttributeData attribute) + { + var syntaxReference = attribute.ApplicationSyntaxReference; + return syntaxReference?.GetSyntax().GetLocation() ?? Location.None; + } + private static bool GetAllowUnknownErrorCodes(INamedTypeSymbol validatorType) { var attribute = GetAttribute(validatorType, KnownTypeNames.GenerateAttribute); diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 51379d0..6b336d6 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -64,6 +64,10 @@ public static string Emit(ValidatorModel model) private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorModel model) { + var schemaCodes = new HashSet( + model.Rules.Select(static rule => rule.Code).Concat(model.Hints.Select(static hint => hint.Code)), + StringComparer.Ordinal + ); var registeredCodes = model .Rules .Where( @@ -71,7 +75,19 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo rule.MetadataSchemaProperties.Length == 0 ) .Select(static rule => rule.Code) - .Concat(model.Hints.Where(static hint => hint.MetadataTypeName is null).Select(static hint => hint.Code)) + .Concat( + model.Hints + .Where( + static hint => hint.MetadataTypeName is null && + hint.MetadataSchemaProperties.Length == 0 + ) + .Select(static hint => hint.Code) + ) + .Concat( + model.Examples + .Where(example => !schemaCodes.Contains(example.Code)) + .Select(static example => example.Code) + ) .Distinct(StringComparer.Ordinal) .OrderBy(static code => code, StringComparer.Ordinal) .ToArray(); @@ -83,7 +99,12 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo .WriteLine(");"); } - foreach (var hint in model.Hints.Where(static hint => hint.MetadataTypeName is not null)) + foreach (var hint in model.Hints + .Where(static hint => hint.MetadataTypeName is not null) + .GroupBy(static hint => new { hint.Code, hint.MetadataTypeName }) + .Select(static group => group.First()) + .OrderBy(static hint => hint.Code, StringComparer.Ordinal) + .ThenBy(static hint => hint.MetadataTypeName, StringComparer.Ordinal)) { writer .Write("builder.WithErrorMetadata<") @@ -93,6 +114,20 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo .WriteLine(");"); } + foreach (var hint in model.Hints + .Where(static hint => hint.MetadataSchemaProperties.Length > 0) + .GroupBy(static hint => hint.Code, StringComparer.Ordinal) + .Select(static group => group.First()) + .OrderBy(static hint => hint.Code, StringComparer.Ordinal)) + { + EmitInlineSchemaConfiguration( + writer, + hint.Code, + hint.MetadataSchemaProperties, + hint.Code + "Metadata" + ); + } + foreach (var rule in model.Rules .Where( static rule => rule.Shape == RuleMetadataShape.Registered && @@ -126,6 +161,7 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo if (registeredCodes.Length > 0 || model.Hints.Length > 0 || + model.Examples.Length > 0 || model.Rules.Any(static rule => rule.Shape != RuleMetadataShape.Registered) || model.Rules.Any( static rule => rule.Shape == RuleMetadataShape.Registered && @@ -140,14 +176,29 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo private static CodeWriter EmitInlineSchemaConfiguration(CodeWriter writer, RuleCallModel rule) { - var requiredKeys = string.Join( - ", ", - rule.MetadataSchemaProperties.Select(static property => ToStringLiteral(property.Key)) + return EmitInlineSchemaConfiguration( + writer, + rule.Code, + rule.MetadataSchemaProperties, + rule.Code + "Metadata" ); + } + + private static CodeWriter EmitInlineSchemaConfiguration( + CodeWriter writer, + string code, + IEnumerable metadataSchemaProperties, + string schemaId + ) + { + var properties = metadataSchemaProperties + .OrderBy(static property => property.Key, StringComparer.Ordinal) + .ToArray(); + var requiredKeys = string.Join(", ", properties.Select(static property => ToStringLiteral(property.Key))); writer .Write("builder.WithErrorMetadata(") - .Write(ToStringLiteral(rule.Code)) + .Write(ToStringLiteral(code)) .WriteLine(", _ => new OpenApiSchema") .WriteLine("{") .IncreaseIndent() @@ -156,10 +207,7 @@ private static CodeWriter EmitInlineSchemaConfiguration(CodeWriter writer, RuleC .WriteLine("{") .IncreaseIndent(); - foreach (var property in rule.MetadataSchemaProperties.OrderBy( - static property => property.Key, - StringComparer.Ordinal - )) + foreach (var property in properties) { writer .Write("[") @@ -177,7 +225,7 @@ private static CodeWriter EmitInlineSchemaConfiguration(CodeWriter writer, RuleC .WriteLine(" }") .DecreaseIndent() .Write("}, ") - .Write(ToStringLiteral(rule.Code + "Metadata")) + .Write(ToStringLiteral(schemaId)) .WriteLine(");"); } @@ -204,7 +252,39 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) writer.WriteLine(");"); } - if (model.Rules.Length > 0) + foreach (var example in model.Examples + .GroupBy( + static example => new + { + example.Code, + Target = example.Target ?? string.Empty, + Metadata = string.Join( + "\u001F", + example.MetadataValues.Select( + static metadata => metadata.Key + "=" + (metadata.Value?.ToString() ?? string.Empty) + ) + ) + } + ) + .Select(static group => group.First()) + .OrderBy(static example => example.Code, StringComparer.Ordinal) + .ThenBy(static example => example.Target, StringComparer.Ordinal)) + { + writer + .Write("builder.WithErrorExample(") + .Write(ToStringLiteral(example.Code)) + .Write(", ") + .Write(example.Target is null ? "null" : ToStringLiteral(example.Target)); + + if (example.MetadataValues.Length > 0) + { + EmitMetadataDictionary(writer.Write(", "), example.MetadataValues); + } + + writer.WriteLine(");"); + } + + if (model.Rules.Length > 0 || model.Examples.Length > 0) { writer.WriteLine(); } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs index 6850fab..064621f 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs @@ -107,7 +107,8 @@ public ValidatorModel( string className, bool allowUnknownErrorCodes, ImmutableArray rules, - ImmutableArray hints + ImmutableArray hints, + ImmutableArray examples ) { MetadataName = metadataName; @@ -118,6 +119,7 @@ ImmutableArray hints AllowUnknownErrorCodes = allowUnknownErrorCodes; Rules = rules; Hints = hints; + Examples = examples; } public string MetadataName { get; } @@ -128,6 +130,7 @@ ImmutableArray hints public bool AllowUnknownErrorCodes { get; } public ImmutableArray Rules { get; } public ImmutableArray Hints { get; } + public ImmutableArray Examples { get; } } internal sealed class RuleCallModel @@ -187,14 +190,38 @@ public MetadataSchemaPropertyModel(string key, string typeName) internal sealed class ErrorHintModel { - public ErrorHintModel(string code, string? metadataTypeName) + public ErrorHintModel( + string code, + string? metadataTypeName, + ImmutableArray metadataSchemaProperties + ) { Code = code; MetadataTypeName = metadataTypeName; + MetadataSchemaProperties = metadataSchemaProperties; } public string Code { get; } public string? MetadataTypeName { get; } + public ImmutableArray MetadataSchemaProperties { get; } +} + +internal sealed class ExampleHintModel +{ + public ExampleHintModel( + string code, + string? target, + ImmutableArray metadataValues + ) + { + Code = code; + Target = target; + MetadataValues = metadataValues; + } + + public string Code { get; } + public string? Target { get; } + public ImmutableArray MetadataValues { get; } } internal enum RuleMetadataShape diff --git a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorMetadataPropertyAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorMetadataPropertyAttribute.cs new file mode 100644 index 0000000..6deea0b --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiErrorMetadataPropertyAttribute.cs @@ -0,0 +1,39 @@ +using System; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Adds one inline metadata schema property to a matching . +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class PortableValidationOpenApiErrorMetadataPropertyAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiErrorMetadataPropertyAttribute(string code, string key, Type type) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(type); + + Code = code; + Key = key; + Type = type; + } + + /// + /// Gets the parent error hint code. + /// + public string Code { get; } + + /// + /// Gets the metadata key. + /// + public string Key { get; } + + /// + /// Gets the metadata value type. + /// + public Type Type { get; } +} diff --git a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs new file mode 100644 index 0000000..b2a7727 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs @@ -0,0 +1,29 @@ +using System; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Adds an illustrative validation error entry to the generated OpenAPI response example. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class PortableValidationOpenApiExampleHintAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleHintAttribute(string code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + Code = code; + } + + /// + /// Gets the documented example error code. + /// + public string Code { get; } + + /// + /// Gets or sets the optional validation target used in the generated example entry. + /// + public string? Target { get; set; } +} diff --git a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleMetadataAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleMetadataAttribute.cs new file mode 100644 index 0000000..4d12a8f --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleMetadataAttribute.cs @@ -0,0 +1,98 @@ +using System; + +namespace Light.PortableResults.Validation.OpenApi; + +/// +/// Adds one constant metadata value to a matching . +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class PortableValidationOpenApiExampleMetadataAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleMetadataAttribute(string code, string key, string? value) + : this(code, key) => + StringValue = value; + + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleMetadataAttribute(string code, string key, int value) + : this(code, key) => + Int64Value = value; + + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleMetadataAttribute(string code, string key, long value) + : this(code, key) => + Int64Value = value; + + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleMetadataAttribute(string code, string key, bool value) + : this(code, key) => + BooleanValue = value; + + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleMetadataAttribute(string code, string key, Type value) + : this(code, key) + { + ArgumentNullException.ThrowIfNull(value); + TypeValue = value; + } + + /// + /// Initializes a new instance of . + /// + public PortableValidationOpenApiExampleMetadataAttribute(string code, string key, double value) + : this(code, key) => + DoubleValue = value; + + private PortableValidationOpenApiExampleMetadataAttribute(string code, string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + Code = code; + Key = key; + } + + /// + /// Gets the parent example error code. + /// + public string Code { get; } + + /// + /// Gets the metadata key. + /// + public string Key { get; } + + /// + /// Gets the optional string value. + /// + public string? StringValue { get; } + + /// + /// Gets the optional integer value. + /// + public long? Int64Value { get; } + + /// + /// Gets the optional Boolean value. + /// + public bool? BooleanValue { get; } + + /// + /// Gets the optional type value. + /// + public Type? TypeValue { get; } + + /// + /// Gets the optional floating-point value. + /// + public double? DoubleValue { get; } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs index 4b72885..1ab7d04 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs @@ -3,11 +3,9 @@ using System.Collections.Immutable; using System.IO; using System.Linq; +using System.Reflection; using FluentAssertions; using Light.PortableResults.AspNetCore.OpenApi; -using Light.PortableResults.Validation; -using Light.PortableResults.Validation.OpenApi; -using Light.PortableResults.Validation.OpenApi.SourceGeneration; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.OpenApi; @@ -183,7 +181,9 @@ in ValidationErrorMessageContext context var source = result.GeneratedSources.Single(); source.Should().Contain("builder.WithErrorMetadata(\"DivisibleBy\", _ => new OpenApiSchema"); source.Should().Contain("[\"divisor\"] = PortableOpenApiSchemaTypeMapper.Map()"); - source.Should().Contain("builder.WithErrorExample(\"DivisibleBy\", null, new Dictionary(StringComparer.Ordinal) { [\"divisor\"] = 3 });"); + source.Should().Contain( + "builder.WithErrorExample(\"DivisibleBy\", null, new Dictionary(StringComparer.Ordinal) { [\"divisor\"] = 3 });" + ); } [Fact] @@ -216,6 +216,155 @@ string value result.GeneratedSources.Single().Should().Contain("builder.WithErrorCodes(\"CustomCode\");"); } + [Fact] + public void Generator_ShouldEmitInlineSchemaAndExampleHints() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + [PortableValidationOpenApiErrorHint("RatingTooLow")] + [PortableValidationOpenApiErrorMetadataProperty("RatingTooLow", "lowerBoundary", typeof(int))] + [PortableValidationOpenApiErrorMetadataProperty("RatingTooLow", "upperBoundary", typeof(int))] + [PortableValidationOpenApiExampleHint("RatingTooLow", Target = "rating")] + [PortableValidationOpenApiExampleMetadata("RatingTooLow", "lowerBoundary", 1)] + [PortableValidationOpenApiExampleMetadata("RatingTooLow", "upperBoundary", 5)] + public sealed partial class InlineHintValidator : Validator + { + public InlineHintValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + int value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain("builder.WithErrorMetadata(\"RatingTooLow\", _ => new OpenApiSchema"); + source.Should().Contain("[\"lowerBoundary\"] = PortableOpenApiSchemaTypeMapper.Map()"); + source.Should().Contain("[\"upperBoundary\"] = PortableOpenApiSchemaTypeMapper.Map()"); + source.Should().Contain( + "builder.WithErrorExample(\"RatingTooLow\", \"rating\", new Dictionary(StringComparer.Ordinal) { [\"lowerBoundary\"] = 1, [\"upperBoundary\"] = 5 });" + ); + source.Should().NotContain("builder.AllowUnknownErrorCodes();"); + } + + [Fact] + public void Generator_ShouldTreatExampleOnlyHintsAsCodeOnlySchemaHints() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class ExampleOnlyHintValidator : Validator + { + public ExampleOnlyHintValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + [PortableValidationOpenApiExampleHint("CustomCode", Target = "name")] + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain("builder.WithErrorCodes(\"CustomCode\");"); + source.Should().Contain("builder.WithErrorExample(\"CustomCode\", \"name\");"); + } + + [Fact] + public void Generator_ShouldEmitMetadataTypeHintAndDeduplicateMatchingCodeHints() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + public sealed class CustomMetadata + { + public int Value { get; init; } + } + + [GeneratePortableValidationOpenApi] + [PortableValidationOpenApiErrorHint("CustomCode", typeof(CustomMetadata))] + public sealed partial class MetadataTypeHintValidator : Validator + { + public MetadataTypeHintValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + [PortableValidationOpenApiErrorHint("CustomCode", typeof(CustomMetadata))] + [PortableValidationOpenApiExampleHint("CustomCode", Target = "name")] + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain("builder.WithErrorMetadata(\"CustomCode\");"); + source.Should().Contain("builder.WithErrorExample(\"CustomCode\", \"name\");"); + source.Should().NotContain("builder.WithErrorCodes(\"CustomCode\");"); + } + + [Fact] + public void Generator_ShouldReportDiagnosticsForMalformedHints() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + public sealed class MetadataOne { } + public sealed class MetadataTwo { } + + [GeneratePortableValidationOpenApi] + [PortableValidationOpenApiErrorHint("CustomCode", typeof(MetadataOne))] + [PortableValidationOpenApiErrorHint("CustomCode", typeof(MetadataTwo))] + [PortableValidationOpenApiErrorMetadataProperty("OrphanCode", "value", typeof(int))] + [PortableValidationOpenApiExampleMetadata("OtherCode", "value", 1)] + public sealed partial class MalformedHintValidator : Validator + { + public MalformedHintValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id) + .Should() + .Contain(["LPRSG0010", "LPRSG0011"]); + } + private static GeneratorRunResult RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); @@ -230,10 +379,10 @@ private static GeneratorRunResult RunGenerator(string source) var driver = CSharpGeneratorDriver.Create(generator) .WithUpdatedParseOptions(parseOptions) .RunGeneratorsAndUpdateCompilation( - compilation, - out var outputCompilation, - out var generatorDiagnostics - ); + compilation, + out var outputCompilation, + out var generatorDiagnostics + ); var runResult = driver.GetRunResult(); var diagnostics = outputCompilation.GetDiagnostics() @@ -260,7 +409,7 @@ private static MetadataReference[] CreateMetadataReferences() return references.Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); } - private static void AddAssembly(ISet references, System.Reflection.Assembly assembly) + private static void AddAssembly(ISet references, Assembly assembly) { if (!string.IsNullOrWhiteSpace(assembly.Location)) { From 8366148c535432b58c6066d12f4136fefd36c719 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 21 May 2026 06:59:51 +0200 Subject: [PATCH 11/17] chore: add AI plan for OpenAPI example messages Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0043-2-openapi-example-messages.md | 204 ++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 ai-plans/0043-2-openapi-example-messages.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 0cf863c..d07e7e4 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -58,6 +58,7 @@ + diff --git a/ai-plans/0043-2-openapi-example-messages.md b/ai-plans/0043-2-openapi-example-messages.md new file mode 100644 index 0000000..170b438 --- /dev/null +++ b/ai-plans/0043-2-openapi-example-messages.md @@ -0,0 +1,204 @@ +# Validation OpenAPI Example Messages + +## Rationale + +The validation OpenAPI source generator can already produce useful response-level examples with concrete error codes, targets, and metadata values. The generated examples still show the generic message `"Validation failed."` for every error entry because the OpenAPI example pipeline has no place to carry a per-error example message. This makes Scalar and Swagger UI less useful than the framework can reasonably provide. + +This plan extends the example model so generated and manually configured validation examples can include representative error messages. The goal is to document the framework's default message shape for inferred rules and allow explicit messages for non-inferable rules, while preserving the current NativeAOT-safe source-generation design. The generated messages are documentation examples, not a promise that every runtime response will use the same text when applications customize validation templates, culture, display names, or error overrides. + +## Acceptance Criteria + +- [ ] OpenAPI response examples can carry a per-error message in addition to the existing code, target, category, and metadata values. +- [ ] `WithErrorExample(...)` and `PortableOpenApiErrorExampleEntry` use a message-before-metadata signature; existing internal call sites are updated because the feature has not been published in a stable release yet. +- [ ] The OpenAPI document transformer uses the supplied example message for both rich validation problem responses and ASP.NET Core-compatible validation problem responses. +- [ ] The validation rule annotation model can describe a default example-message template for built-in and explicitly annotated custom rules without referencing ASP.NET Core or `Microsoft.OpenApi`. +- [ ] Built-in validation rules are annotated with default example-message templates that match the framework's default `ValidationErrorTemplates` as closely as possible. +- [ ] Message-template parsing supports literal braces via `{{` and `}}`. +- [ ] The source generator emits per-rule example messages when it can resolve every value needed by the message template at generation time. +- [ ] The source generator omits the rule-specific example message when required inputs cannot be resolved statically, allowing the OpenAPI transformer to apply its centralized fallback message. +- [ ] The source generator reports Roslyn warning diagnostics for malformed message templates, including placeholders that cannot bind to `displayName` or to metadata declared by the annotated rule. +- [ ] Explicit OpenAPI example hints can supply a message for opaque or custom validation paths. +- [ ] Generated source remains deterministic, NativeAOT-safe, reflection-free, and independent of consumer implicit usings, global usings, aliases, and local using directives. +- [ ] Automated tests are written for builder APIs, document transformation, source-generator output, inferred built-in rule messages, explicit hint messages, fallback behavior, and non-constant message omission. +- [ ] Documentation is updated to explain that generated messages are representative examples based on framework defaults and may differ from runtime messages when applications customize validation behavior. + +## Technical Details + +### Runtime Example Model + +Extend `PortableOpenApiErrorExampleEntry` with a nullable `Message` property. Because the library is still pre-stable and this example API has not been published in a stable release, the existing constructor can be reshaped directly: + +```csharp +public PortableOpenApiErrorExampleEntry( + string code, + string? target, + string? message, + IReadOnlyDictionary? metadata +) +``` + +The property assignment stores `Message = message`. Equality and hashing include `Message` so two otherwise-identical example entries with different messages remain distinct. + +Reshape `PortableProblemOpenApiBuilder.WithErrorExample(...)` and `PortableValidationProblemOpenApiBuilder.WithErrorExample(...)` the same way. Put `message` before `metadata` so the API reads like the resulting error entry: + +```csharp +builder.WithErrorExample( + code: "NotEmpty", + target: "id", + message: "id must not be empty", + metadata: null); +``` + +Callers that do not have a specific message pass `message: null`. All existing repository call sites should be updated in the same change set. + +The OpenAPI document transformer should use: + +```csharp +entry.Message ?? "Validation failed." +``` + +for the rich `errors[*].message` value and for the ASP.NET Core-compatible `errors[target][index]` message value. The fallback stays centralized and unchanged for callers that do not opt into message examples. + +### Rule Message Metadata + +Add source-generator-facing message metadata to `Light.PortableResults.Validation.Definitions`, next to `ValidationRuleAttribute` and `ValidationRuleMetadataAttribute`. The attribute should describe a compile-time message template, not runtime behavior: + +```csharp +[ValidationRuleMessage("{displayName} must not be empty")] +``` + +The supported placeholders should be deliberately small: + +- `{displayName}` for the inferred or explicit display name. +- Metadata placeholders such as `{minLength}`, `{maxLength}`, `{lowerBoundary}`, `{upperBoundary}`, and `{comparativeValue}` for values already modeled with `[ValidationRuleMetadata]`. + +The attribute must use source-generator-friendly constructor shapes only. A single string template is sufficient for this iteration. Do not introduce delegates, resource lookups, arbitrary object graphs, or references to OpenAPI types. + +Template parsing should support literal braces by escaping them as doubled braces: `{{` emits `{`, and `}}` emits `}`. A single unmatched `{` or `}` is a malformed template. + +The generator should validate templates against the annotated rule method. A placeholder is valid only when it is `displayName` or when it matches a metadata key declared by `[ValidationRuleMetadata]` on the same method. Unknown placeholders and malformed brace sequences are annotation/configuration issues and should produce Roslyn warning diagnostics at the rule or template attribute location. The warning should suppress only the rule-specific message emission for affected rule calls; schema and non-message example generation can continue. This is different from a valid placeholder whose call-site value is not a compile-time constant; that case is not malformed and should only suppress the rule-specific example message for that particular call site. + +Built-in check methods should be annotated with templates that mirror the default `ValidationErrorTemplates`: + +```csharp +[ValidationRule(ValidationErrorCodes.NotEmpty)] +[ValidationRuleMessage("{displayName} must not be empty")] +public static Check IsNotEmpty(...) + +[ValidationRule(ValidationErrorCodes.InRange, ValidationRuleMetadataShape.TypedRange)] +[ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] +[ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] +[ValidationRuleMessage("{displayName} must be between {lowerBoundary} and {upperBoundary}")] +public static Check IsInRange(...) +``` + +For rules whose runtime default message depends on formatting that is hard to represent statically, prefer a conservative template that matches the normal case. If a rule cannot be represented without misleading users, omit the rule-message annotation and let the example fall back to `"Validation failed."`. + +### Display Name Inference + +The generator already infers the JSON target for simple `context.Check(dto.SomeProperty)` calls. It should also determine the example display name used by message templates. + +Use this precedence: + +1. A constant `displayName` argument supplied to `ValidationContext.Check(...)`. +2. The inferred normalized target when available. +3. No display name, which means no generated message for that example. + +This intentionally differs from fully executing runtime validation. It gives useful examples for the common source-generator-supported shape while avoiding a fake message for expressions whose target cannot be inferred. + +The generator should not instantiate `ValidationContext`, `ValidationErrorDefinition`, `ValidationErrorTemplates`, or validators to compute messages. Doing so would break the current architecture: analyzers do not reference runtime assemblies normally, generated contracts stay NativeAOT-safe, and the generator remains a pure static analysis pass. + +### Message Formatting + +Extend the generator model with nullable message values: + +- `RuleCallModel.Message` +- `ExampleHintModel.Message` + +When a rule has a valid message template, the analyzer substitutes placeholders only if every referenced value is known: + +- `{displayName}` requires a resolved example display name. +- Metadata placeholders require matching `MetadataValueModel` entries with `HasConstantValue == true`. + +Parameter formatting should be simple and deterministic. It should use invariant-culture literal formatting consistent with the existing generated metadata literals, not the application's runtime culture. This should be documented as part of the "representative example" behavior. + +If substitution cannot be completed, the analyzer leaves `Message` as `null` and does not emit a diagnostic. Missing rule-specific example messages are not contract violations; they only mean the OpenAPI transformer will use its centralized fallback message for that entry. + +The no-diagnostic rule applies only after the template itself has been validated. For example, `{minimum}` on a rule that declares only `minLength` is a diagnostic because the annotation is wrong. `{minLength}` on a call like `HasMinLength(configuredMinimum)` is not a diagnostic when `configuredMinimum` is not a compile-time constant; the generator simply cannot produce an exact example message for that call site. + +The emitter should use the message-aware overload when `Message` is non-null: + +```csharp +builder.WithErrorExample( + "InRange", + "rating", + "rating must be between 1 and 5", + new Dictionary(StringComparer.Ordinal) + { + ["lowerBoundary"] = 1, + ["upperBoundary"] = 5 + }); +``` + +When `Message` is null, emit the same message-before-metadata call shape with `null` as the message argument: + +```csharp +builder.WithErrorExample( + "NotEmpty", + "id", + null, + null); +``` + +### Explicit Example Hints + +Extend `PortableValidationOpenApiExampleHintAttribute` with a nullable settable `Message` property: + +```csharp +[PortableValidationOpenApiExampleHint("MovieAlreadyRated", Target = "movieId", Message = "movieId has already been rated")] +``` + +The message property is only example content. It does not declare schema, does not affect error-code exhaustiveness, and does not participate in metadata-shape conflict checks. + +Example-hint deduplication should include `Message` so users can intentionally document distinct entries that differ by text. Existing structural diagnostics from 0043-1 still apply; if the current implementation allows only one example hint per code per scope, that rule remains unless the implementation deliberately broadens it. + +### Compatibility and Boundaries + +This change is intentionally source-breaking for the unpublished example-builder API shape: repository call sites that pass metadata positionally must add the new `message` argument before metadata. This is acceptable because the library is still pre-stable and the feature has not been published in a stable release. Existing OpenAPI documents may gain more specific example messages when validators are regenerated, but the schema contract is unchanged. + +Do not attempt to model: + +- `ErrorOverrides.Message` +- application-specific `ValidationErrorTemplates` +- localized messages +- culture-specific runtime formatting +- messages produced by imperative `Custom(...)`, `Must(...)`, or delegate-based validation unless supplied through explicit example hints + +Those paths remain runtime concerns or explicit documentation-hint concerns. The source generator should prefer omitting a rule-specific message over emitting inaccurate text, while the transformer remains responsible for providing the generic fallback. + +### Tests + +Add focused unit tests for the runtime OpenAPI side: + +- `WithErrorExample(...)` with `message: null` still produces `"Validation failed."`. +- `WithErrorExample(...)` with a message produces that message in rich validation examples. +- `WithErrorExample(...)` with a message produces that message in ASP.NET Core-compatible validation examples. +- Example-entry equality and hashing include the message. + +Add source-generation tests for: + +- built-in no-metadata messages such as `NotEmpty`; +- built-in single-parameter messages such as `MinLength`; +- built-in range messages such as `InRange` and `LengthInRange`; +- explicit constant `displayName`; +- non-constant metadata values causing message omission while preserving code, target, and metadata schema generation; +- invalid template placeholders producing Roslyn diagnostics; +- escaped literal braces in message templates; +- explicit `PortableValidationOpenApiExampleHintAttribute.Message`; +- annotated custom rule messages using metadata placeholders. + +End-to-end document-generation tests should assert the concrete message text in the generated OpenAPI example for the movie-rating sample or a similarly representative validator. + +### Documentation + +Update the source-generation documentation to show before-and-after OpenAPI examples with real messages. The docs should state plainly that generated messages are representative defaults. Runtime responses can differ when users configure validation templates, display names, target normalization, culture, or error overrides. Users who need exact documentation for opaque or customized flows should use explicit example hints or endpoint-level manual configuration. From 635b8941ccba803649e54998391c4307f97e1dfb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 21 May 2026 07:28:14 +0200 Subject: [PATCH 12/17] chore: refined plan 43-2 for OpenAPI example messages Signed-off-by: Kenny Pflug --- ai-plans/0043-2-openapi-example-messages.md | 24 +++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ai-plans/0043-2-openapi-example-messages.md b/ai-plans/0043-2-openapi-example-messages.md index 170b438..57f5d50 100644 --- a/ai-plans/0043-2-openapi-example-messages.md +++ b/ai-plans/0043-2-openapi-example-messages.md @@ -13,10 +13,10 @@ This plan extends the example model so generated and manually configured validat - [ ] The OpenAPI document transformer uses the supplied example message for both rich validation problem responses and ASP.NET Core-compatible validation problem responses. - [ ] The validation rule annotation model can describe a default example-message template for built-in and explicitly annotated custom rules without referencing ASP.NET Core or `Microsoft.OpenApi`. - [ ] Built-in validation rules are annotated with default example-message templates that match the framework's default `ValidationErrorTemplates` as closely as possible. -- [ ] Message-template parsing supports literal braces via `{{` and `}}`. +- [ ] Message-template parsing supports literal braces via `{{` and `}}`. Placeholder names are case-sensitive, must match the metadata key (or `displayName`) exactly, and disallow inner whitespace (`{ minLength }` is malformed). - [ ] The source generator emits per-rule example messages when it can resolve every value needed by the message template at generation time. - [ ] The source generator omits the rule-specific example message when required inputs cannot be resolved statically, allowing the OpenAPI transformer to apply its centralized fallback message. -- [ ] The source generator reports Roslyn warning diagnostics for malformed message templates, including placeholders that cannot bind to `displayName` or to metadata declared by the annotated rule. +- [ ] The source generator reports two distinct Roslyn warning diagnostics for message-template annotation problems: one for unknown placeholders (a name that is neither `displayName` nor a metadata key declared by the rule) and one for malformed brace sequences (unmatched `{` or `}`). - [ ] Explicit OpenAPI example hints can supply a message for opaque or custom validation paths. - [ ] Generated source remains deterministic, NativeAOT-safe, reflection-free, and independent of consumer implicit usings, global usings, aliases, and local using directives. - [ ] Automated tests are written for builder APIs, document transformation, source-generator output, inferred built-in rule messages, explicit hint messages, fallback behavior, and non-constant message omission. @@ -49,7 +49,7 @@ builder.WithErrorExample( metadata: null); ``` -Callers that do not have a specific message pass `message: null`. All existing repository call sites should be updated in the same change set. +Callers that do not have a specific message pass `message: null`. The builder methods keep `message` and `metadata` as optional parameters with `null` defaults so hand-written calls such as `WithErrorExample("Code", target: "x", message: "…")` remain readable; only the parameter order changes. All existing repository call sites should be updated in the same change set. The OpenAPI document transformer should use: @@ -70,13 +70,18 @@ Add source-generator-facing message metadata to `Light.PortableResults.Validatio The supported placeholders should be deliberately small: - `{displayName}` for the inferred or explicit display name. -- Metadata placeholders such as `{minLength}`, `{maxLength}`, `{lowerBoundary}`, `{upperBoundary}`, and `{comparativeValue}` for values already modeled with `[ValidationRuleMetadata]`. +- Metadata placeholders such as `{minLength}`, `{maxLength}`, `{lowerBoundary}`, `{upperBoundary}`, and `{comparativeValue}` for values already modeled with `[ValidationRuleMetadata]`. The exact placeholder names must match the string values of the corresponding `ValidationErrorMetadataKeys` constants verbatim; if a constant value differs from the spelling used here, the template should use the constant's value. Placeholder matching is case-sensitive. The attribute must use source-generator-friendly constructor shapes only. A single string template is sufficient for this iteration. Do not introduce delegates, resource lookups, arbitrary object graphs, or references to OpenAPI types. -Template parsing should support literal braces by escaping them as doubled braces: `{{` emits `{`, and `}}` emits `}`. A single unmatched `{` or `}` is a malformed template. +Template parsing should support literal braces by escaping them as doubled braces: `{{` emits `{`, and `}}` emits `}`. A single unmatched `{` or `}` is a malformed template. Inside a placeholder, only the bare identifier is permitted: leading or trailing whitespace, format specifiers, and alignment syntax are not supported and should be treated as malformed. -The generator should validate templates against the annotated rule method. A placeholder is valid only when it is `displayName` or when it matches a metadata key declared by `[ValidationRuleMetadata]` on the same method. Unknown placeholders and malformed brace sequences are annotation/configuration issues and should produce Roslyn warning diagnostics at the rule or template attribute location. The warning should suppress only the rule-specific message emission for affected rule calls; schema and non-message example generation can continue. This is different from a valid placeholder whose call-site value is not a compile-time constant; that case is not malformed and should only suppress the rule-specific example message for that particular call site. +The generator should validate templates against the annotated rule method. A placeholder is valid only when it is `displayName` or when it matches a metadata key declared by `[ValidationRuleMetadata]` on the same method. Two distinct Roslyn warning diagnostics are emitted at the rule or template attribute location: + +- One for unknown placeholders — a name that is neither `displayName` nor a metadata key declared by the rule. +- One for malformed brace sequences — unmatched `{` or `}`, or placeholders containing anything other than a bare identifier. + +Both warnings suppress only the rule-specific message emission for affected rule calls; schema and non-message example generation continue. This is different from a valid placeholder whose call-site value is not a compile-time constant; that case is not malformed and should only suppress the rule-specific example message for that particular call site without raising a diagnostic. Built-in check methods should be annotated with templates that mirror the default `ValidationErrorTemplates`: @@ -94,6 +99,8 @@ public static Check IsInRange(...) For rules whose runtime default message depends on formatting that is hard to represent statically, prefer a conservative template that matches the normal case. If a rule cannot be represented without misleading users, omit the rule-message annotation and let the example fall back to `"Validation failed."`. +Built-in rule coverage guidance: annotate every built-in check whose default `ValidationErrorTemplates` entry can be reproduced statically with the available placeholders. This includes at least the no-metadata templates (`NotEmpty`, `Empty`), the comparable-value templates (`EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`) using `{comparativeValue}`, the typed-range templates (`InRange`, `NotInRange`) using `{lowerBoundary}` and `{upperBoundary}`, and the length-based templates (`MinLength`, `MaxLength`, `LengthInRange`) including the trailing `" characters long"` segment from `DisplayNameWithParameter`/`DisplayNameWithRange`. Implementers should walk through `ValidationErrorTemplates` and annotate each entry whose shape can be expressed without runtime formatting; skip any whose default depends on locale-sensitive formatting or runtime-only state. + ### Display Name Inference The generator already infers the JSON target for simple `context.Check(dto.SomeProperty)` calls. It should also determine the example display name used by message templates. @@ -106,6 +113,8 @@ Use this precedence: This intentionally differs from fully executing runtime validation. It gives useful examples for the common source-generator-supported shape while avoiding a fake message for expressions whose target cannot be inferred. +Falling back to the normalized JSON target means generated messages read with the camelCase property name (for example, `"firstName must not be empty"`). At runtime, the validator typically substitutes the unnormalized property identifier or an application-configured friendly name, so generated text will deliberately differ. This is acceptable because the typical consumer is a web API where the JSON name is the most accurate identifier for documentation, and because the documentation already states that generated messages are representative defaults. + The generator should not instantiate `ValidationContext`, `ValidationErrorDefinition`, `ValidationErrorTemplates`, or validators to compute messages. Doing so would break the current architecture: analyzers do not reference runtime assemblies normally, generated contracts stay NativeAOT-safe, and the generator remains a pure static analysis pass. ### Message Formatting @@ -192,7 +201,8 @@ Add source-generation tests for: - built-in range messages such as `InRange` and `LengthInRange`; - explicit constant `displayName`; - non-constant metadata values causing message omission while preserving code, target, and metadata schema generation; -- invalid template placeholders producing Roslyn diagnostics; +- unknown template placeholders producing the unknown-placeholder Roslyn diagnostic; +- malformed brace sequences (unmatched `{`/`}`, whitespace or format specifiers inside placeholders) producing the malformed-template Roslyn diagnostic; - escaped literal braces in message templates; - explicit `PortableValidationOpenApiExampleHintAttribute.Message`; - annotated custom rule messages using metadata placeholders. From 98ac84fe718e70766c1e1f9a74110029265c5967 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 21 May 2026 21:09:41 +0200 Subject: [PATCH 13/17] feat: add message support for OpenAPI Source Generator Signed-off-by: Kenny Pflug --- README.md | 10 +- ai-plans/0043-2-openapi-example-messages.md | 26 +- ...rtableResultsOpenApiDocumentTransformer.cs | 6 +- .../PortableOpenApiErrorExampleEntry.cs | 9 + .../PortableProblemOpenApiBuilder.cs | 3 +- ...PortableValidationProblemOpenApiBuilder.cs | 3 +- .../DiagnosticDescriptors.cs | 20 ++ .../KnownTypeNames.cs | 2 + .../ValidatorOpenApiAnalyzer.cs | 287 +++++++++++++++++- .../ValidatorOpenApiEmitter.cs | 16 +- .../ValidatorOpenApiModels.cs | 6 + ...BuiltInValidationErrorBuilderExtensions.cs | 76 ++++- ...leValidationOpenApiExampleHintAttribute.cs | 5 + .../Checks.Comparable.cs | 7 + .../Checks.Count.cs | 9 + .../Checks.Empty.cs | 8 + .../Checks.Enums.cs | 7 +- .../Checks.Equality.cs | 2 + .../Checks.Null.cs | 2 + .../Checks.Strings.cs | 8 + .../Definitions/ValidationRuleAttributes.cs | 21 ++ .../PortableOpenApiResponseBuilderTests.cs | 40 +++ ...eResultsOpenApiDocumentTransformerTests.cs | 66 ++++ ...PortableValidationOpenApiGeneratorTests.cs | 215 ++++++++++++- ...eratedValidationOpenApiIntegrationTests.cs | 4 + 25 files changed, 808 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 4d8263e..6d0254b 100644 --- a/README.md +++ b/README.md @@ -1465,7 +1465,7 @@ The generated contract calls the same builder APIs you would write by hand, then The generator analyzes top-level `context.Check(...).Rule(...)` chains in synchronous `Validator` and `Validator` implementations. It supports built-in annotated rules, assignments that consume a checked value, explicit error hints via `[PortableValidationOpenApiErrorHint]`, and user-defined check methods annotated with `[ValidationRule]` plus optional `[ValidationErrorContract]` metadata definitions. Checks inside `if`, `switch`, loops, lambdas, local functions, `try`, or `using` blocks are skipped with a warning; lift the check to a top-level statement or add explicit hints when those errors must appear in the OpenAPI schema. -When metadata arguments are compile-time constants, such as `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`, the generated contract also contributes a response-level OpenAPI example. Scalar and Swagger UI show these concrete values in the validation problem example body. Non-constant metadata arguments still get documented schemas, but that specific call site is omitted from the example. Delegate-based `Must(...)`, `Custom(...)`, `ErrorOverrides`, async validators, source-null errors emitted before `PerformValidation`, child validators, and complex target inference are intentionally outside the first-generation analysis. +When metadata arguments are compile-time constants, such as `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`, the generated contract also contributes a response-level OpenAPI example. Scalar and Swagger UI show these concrete values in the validation problem example body, including representative default messages such as `"comment must be between 10 and 1000 characters long"` and `"rating must be between 1 and 5"`. These messages are documentation examples based on the framework defaults; runtime responses can differ when applications configure validation templates, display names, target normalization, culture, or error overrides. Non-constant metadata arguments still get documented schemas and can still appear as code/target examples, but the generated message and concrete metadata values are omitted for that call site so the OpenAPI transformer uses the generic fallback message. Delegate-based `Must(...)`, `Custom(...)`, `ErrorOverrides`, async validators, source-null errors emitted before `PerformValidation`, child validators, and complex target inference are intentionally outside the first-generation analysis. Use explicit hints when the validator emits known validation error contracts that the generator cannot infer. For an opaque `Custom(...)` path that only needs a code in the schema, add a code-only hint at the validator or `PerformValidation` method level: @@ -1490,10 +1490,14 @@ If the opaque path has endpoint-specific metadata, either point to a metadata ty Hints compose with inferred rules. Matching schema shapes are deduplicated, while conflicting metadata shapes for the same code are reported by the generator because the emitted OpenAPI contract would otherwise be ambiguous. Hinting a code never makes the generated response non-exhaustive and never calls `AllowUnknownErrorCodes()` by itself. -You can also provide response-example entries for opaque paths. An example-only hint documents the code as code-only, so the common case does not need a separate `[PortableValidationOpenApiErrorHint]`. Metadata values are compile-time constants declared with companion attributes: +You can also provide response-example entries for opaque paths. An example-only hint documents the code as code-only, so the common case does not need a separate `[PortableValidationOpenApiErrorHint]`. Use `Message` when the opaque path needs exact example text; otherwise the OpenAPI transformer writes `"Validation failed."`. Metadata values are compile-time constants declared with companion attributes: ```csharp -[PortableValidationOpenApiExampleHint("RatingTooLow", Target = "rating")] +[PortableValidationOpenApiExampleHint( + "RatingTooLow", + Target = "rating", + Message = "rating must be at least 1" +)] [PortableValidationOpenApiExampleMetadata("RatingTooLow", "lowerBoundary", 1)] [PortableValidationOpenApiExampleMetadata("RatingTooLow", "upperBoundary", 5)] ``` diff --git a/ai-plans/0043-2-openapi-example-messages.md b/ai-plans/0043-2-openapi-example-messages.md index 57f5d50..84ed905 100644 --- a/ai-plans/0043-2-openapi-example-messages.md +++ b/ai-plans/0043-2-openapi-example-messages.md @@ -8,19 +8,19 @@ This plan extends the example model so generated and manually configured validat ## Acceptance Criteria -- [ ] OpenAPI response examples can carry a per-error message in addition to the existing code, target, category, and metadata values. -- [ ] `WithErrorExample(...)` and `PortableOpenApiErrorExampleEntry` use a message-before-metadata signature; existing internal call sites are updated because the feature has not been published in a stable release yet. -- [ ] The OpenAPI document transformer uses the supplied example message for both rich validation problem responses and ASP.NET Core-compatible validation problem responses. -- [ ] The validation rule annotation model can describe a default example-message template for built-in and explicitly annotated custom rules without referencing ASP.NET Core or `Microsoft.OpenApi`. -- [ ] Built-in validation rules are annotated with default example-message templates that match the framework's default `ValidationErrorTemplates` as closely as possible. -- [ ] Message-template parsing supports literal braces via `{{` and `}}`. Placeholder names are case-sensitive, must match the metadata key (or `displayName`) exactly, and disallow inner whitespace (`{ minLength }` is malformed). -- [ ] The source generator emits per-rule example messages when it can resolve every value needed by the message template at generation time. -- [ ] The source generator omits the rule-specific example message when required inputs cannot be resolved statically, allowing the OpenAPI transformer to apply its centralized fallback message. -- [ ] The source generator reports two distinct Roslyn warning diagnostics for message-template annotation problems: one for unknown placeholders (a name that is neither `displayName` nor a metadata key declared by the rule) and one for malformed brace sequences (unmatched `{` or `}`). -- [ ] Explicit OpenAPI example hints can supply a message for opaque or custom validation paths. -- [ ] Generated source remains deterministic, NativeAOT-safe, reflection-free, and independent of consumer implicit usings, global usings, aliases, and local using directives. -- [ ] Automated tests are written for builder APIs, document transformation, source-generator output, inferred built-in rule messages, explicit hint messages, fallback behavior, and non-constant message omission. -- [ ] Documentation is updated to explain that generated messages are representative examples based on framework defaults and may differ from runtime messages when applications customize validation behavior. +- [x] OpenAPI response examples can carry a per-error message in addition to the existing code, target, category, and metadata values. +- [x] `WithErrorExample(...)` and `PortableOpenApiErrorExampleEntry` use a message-before-metadata signature; existing internal call sites are updated because the feature has not been published in a stable release yet. +- [x] The OpenAPI document transformer uses the supplied example message for both rich validation problem responses and ASP.NET Core-compatible validation problem responses. +- [x] The validation rule annotation model can describe a default example-message template for built-in and explicitly annotated custom rules without referencing ASP.NET Core or `Microsoft.OpenApi`. +- [x] Built-in validation rules are annotated with default example-message templates that match the framework's default `ValidationErrorTemplates` as closely as possible. +- [x] Message-template parsing supports literal braces via `{{` and `}}`. Placeholder names are case-sensitive, must match the metadata key (or `displayName`) exactly, and disallow inner whitespace (`{ minLength }` is malformed). +- [x] The source generator emits per-rule example messages when it can resolve every value needed by the message template at generation time. +- [x] The source generator omits the rule-specific example message when required inputs cannot be resolved statically, allowing the OpenAPI transformer to apply its centralized fallback message. +- [x] The source generator reports two distinct Roslyn warning diagnostics for message-template annotation problems: one for unknown placeholders (a name that is neither `displayName` nor a metadata key declared by the rule) and one for malformed brace sequences (unmatched `{` or `}`). +- [x] Explicit OpenAPI example hints can supply a message for opaque or custom validation paths. +- [x] Generated source remains deterministic, NativeAOT-safe, reflection-free, and independent of consumer implicit usings, global usings, aliases, and local using directives. +- [x] Automated tests are written for builder APIs, document transformation, source-generator output, inferred built-in rule messages, explicit hint messages, fallback behavior, and non-constant message omission. +- [x] Documentation is updated to explain that generated messages are representative examples based on framework defaults and may differ from runtime messages when applications customize validation behavior. ## Technical Details diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 029fd16..3bdb03e 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -25,6 +25,8 @@ namespace Light.PortableResults.AspNetCore.OpenApi.Generation; /// public sealed class PortableResultsOpenApiDocumentTransformer : IOpenApiDocumentTransformer { + private const string DefaultValidationExampleMessage = "Validation failed."; + private readonly IErrorMetadataContractRegistry _errorMetadataContractRegistry; private readonly PortableResultsHttpWriteOptions _writeOptions; @@ -267,7 +269,7 @@ IReadOnlyList entries { var error = new JsonObject { - ["message"] = "Validation failed.", + ["message"] = entry.Message ?? DefaultValidationExampleMessage, ["code"] = entry.Code, ["target"] = entry.Target, ["category"] = ErrorCategory.Validation.ToString() @@ -300,7 +302,7 @@ IReadOnlyList entries var index = nextIndexByTarget[target]; nextIndexByTarget[target] = index + 1; - messages.Add((JsonNode?) JsonValue.Create("Validation failed.")); + messages.Add((JsonNode?) JsonValue.Create(entry.Message ?? DefaultValidationExampleMessage)); var detail = new JsonObject { diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs index 2893c5e..c512676 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs @@ -14,6 +14,7 @@ public sealed class PortableOpenApiErrorExampleEntry : IEquatable? metadata ) { @@ -21,6 +22,7 @@ public PortableOpenApiErrorExampleEntry( Code = code; Target = target; + Message = message; Metadata = metadata; } @@ -34,6 +36,11 @@ public PortableOpenApiErrorExampleEntry( /// public string? Target { get; } + /// + /// Gets the optional example validation message. + /// + public string? Message { get; } + /// /// Gets the optional error metadata values. /// @@ -49,6 +56,7 @@ public bool Equals(PortableOpenApiErrorExampleEntry? other) return string.Equals(Code, other.Code, StringComparison.Ordinal) && string.Equals(Target, other.Target, StringComparison.Ordinal) && + string.Equals(Message, other.Message, StringComparison.Ordinal) && MetadataEquals(Metadata, other.Metadata); } @@ -61,6 +69,7 @@ public override int GetHashCode() var hashCode = new HashCode(); hashCode.Add(Code, StringComparer.Ordinal); hashCode.Add(Target, StringComparer.Ordinal); + hashCode.Add(Message, StringComparer.Ordinal); if (Metadata is not null) { foreach (var (key, value) in Metadata) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs index 45f9a15..0d0b9b9 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableProblemOpenApiBuilder.cs @@ -48,12 +48,13 @@ public PortableProblemOpenApiBuilder WithErrorCodes(params string[] codes) public PortableProblemOpenApiBuilder WithErrorExample( string code, string? target, + string? message = null, IReadOnlyDictionary? metadata = null ) { _attribute.ErrorExamples = PortableOpenApiBuilderUtilities.AppendExamples( _attribute.ErrorExamples, - new PortableOpenApiErrorExampleEntry(code, target, metadata) + new PortableOpenApiErrorExampleEntry(code, target, message, metadata) ); return this; } diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs index 9fb9437..2873d2f 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableValidationProblemOpenApiBuilder.cs @@ -50,12 +50,13 @@ public PortableValidationProblemOpenApiBuilder WithErrorCodes(params string[] co public PortableValidationProblemOpenApiBuilder WithErrorExample( string code, string? target, + string? message = null, IReadOnlyDictionary? metadata = null ) { _attribute.ErrorExamples = PortableOpenApiBuilderUtilities.AppendExamples( _attribute.ErrorExamples, - new PortableOpenApiErrorExampleEntry(code, target, metadata) + new PortableOpenApiErrorExampleEntry(code, target, message, metadata) ); return this; } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs index 2ab01e1..fc77c14 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs @@ -128,4 +128,24 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true, helpLinkUri: HelpLinkBase ); + + public static readonly DiagnosticDescriptor UnknownMessageTemplatePlaceholder = new ( + "LPRSG0013", + "Validation rule message template has an unknown placeholder", + "Validation rule message template for rule '{0}' contains unknown placeholder '{1}'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor MalformedMessageTemplate = new ( + "LPRSG0014", + "Validation rule message template is malformed", + "Validation rule message template for rule '{0}' is malformed: {1}", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs index f90b59d..5f57662 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs @@ -16,6 +16,8 @@ internal static class KnownTypeNames "Light.PortableResults.Validation.Definitions.ValidationRuleAttribute"; public const string ValidationRuleMetadataAttribute = "Light.PortableResults.Validation.Definitions.ValidationRuleMetadataAttribute"; + public const string ValidationRuleMessageAttribute = + "Light.PortableResults.Validation.Definitions.ValidationRuleMessageAttribute"; public const string ValidationErrorContractAttribute = "Light.PortableResults.Validation.Definitions.ValidationErrorContractAttribute"; public const string ValidationErrorMetadataContractAttribute = diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs index 168cc0c..8bac423 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; +using System.Text; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -250,6 +252,7 @@ CancellationToken cancellationToken } var target = TryInferTarget(semanticModel, invocations[0], sourceParameterName, cancellationToken); + var displayName = TryGetExplicitDisplayName(semanticModel, invocations[0], cancellationToken) ?? target; for (var i = 1; i < invocations.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -295,6 +298,7 @@ CancellationToken cancellationToken symbol, ruleAttribute, target, + displayName, diagnostics, cancellationToken ); @@ -311,6 +315,7 @@ CancellationToken cancellationToken IMethodSymbol symbol, AttributeData ruleAttribute, string? target, + string? displayName, ICollection diagnostics, CancellationToken cancellationToken ) @@ -393,16 +398,229 @@ out var metadataSchemaProperties } var typedValueTypeName = ResolveTypedValueTypeName(symbol, shape); + var message = CreateExampleMessage(definitionSymbol, symbol.Name, displayName, metadataValues, diagnostics); return new RuleCallModel( code!, shape, target, + message, typedValueTypeName, metadataValues.ToImmutable(), metadataSchemaProperties ); } + private static string? CreateExampleMessage( + IMethodSymbol definitionSymbol, + string ruleName, + string? displayName, + ImmutableArray.Builder metadataValues, + ICollection diagnostics + ) + { + var messageAttribute = GetAttribute(definitionSymbol, KnownTypeNames.ValidationRuleMessageAttribute); + var template = messageAttribute?.ConstructorArguments.Length > 0 ? + messageAttribute.ConstructorArguments[0].Value as string : + null; + if (string.IsNullOrWhiteSpace(template)) + { + return null; + } + + var allowedPlaceholders = new HashSet( + metadataValues.Select(static metadata => metadata.Key), + StringComparer.Ordinal + ) { "displayName" }; + if (!TryParseMessageTemplate( + template!, + allowedPlaceholders, + ruleName, + messageAttribute!, + diagnostics, + out var parts + )) + { + return null; + } + + var replacements = new Dictionary(StringComparer.Ordinal); + if (displayName is not null) + { + replacements.Add("displayName", displayName); + } + + foreach (var metadata in metadataValues) + { + if (!metadata.HasConstantValue) + { + continue; + } + + replacements[metadata.Key] = FormatMessageValue(metadata.Value); + } + + var builder = new StringBuilder(template!.Length); + foreach (var part in parts) + { + if (part.Placeholder is null) + { + builder.Append(part.Text); + continue; + } + + if (!replacements.TryGetValue(part.Placeholder, out var replacement)) + { + return null; + } + + builder.Append(replacement); + } + + return builder.ToString(); + } + + private static bool TryParseMessageTemplate( + string template, + ISet allowedPlaceholders, + string ruleName, + AttributeData messageAttribute, + ICollection diagnostics, + out ImmutableArray parts + ) + { + var builder = ImmutableArray.CreateBuilder(); + var literal = new StringBuilder(); + var isValid = true; + + for (var i = 0; i < template.Length; i++) + { + var c = template[i]; + if (c == '{') + { + if (i + 1 < template.Length && template[i + 1] == '{') + { + literal.Append('{'); + i++; + continue; + } + + var closeIndex = template.IndexOf('}', i + 1); + if (closeIndex < 0) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.MalformedMessageTemplate, + GetAttributeLocation(messageAttribute), + ruleName, + "unmatched '{'" + ) + ); + isValid = false; + break; + } + + var placeholder = template.Substring(i + 1, closeIndex - i - 1); + if (!IsBarePlaceholderName(placeholder)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.MalformedMessageTemplate, + GetAttributeLocation(messageAttribute), + ruleName, + $"placeholder '{{{placeholder}}}' is not a bare identifier" + ) + ); + isValid = false; + i = closeIndex; + continue; + } + + if (literal.Length > 0) + { + builder.Add(MessageTemplatePart.Literal(literal.ToString())); + literal.Clear(); + } + + if (!allowedPlaceholders.Contains(placeholder)) + { + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.UnknownMessageTemplatePlaceholder, + GetAttributeLocation(messageAttribute), + ruleName, + placeholder + ) + ); + isValid = false; + } + + builder.Add(MessageTemplatePart.PlaceholderValue(placeholder)); + i = closeIndex; + continue; + } + + if (c == '}') + { + if (i + 1 < template.Length && template[i + 1] == '}') + { + literal.Append('}'); + i++; + continue; + } + + diagnostics.Add( + Diagnostic.Create( + DiagnosticDescriptors.MalformedMessageTemplate, + GetAttributeLocation(messageAttribute), + ruleName, + "unmatched '}'" + ) + ); + isValid = false; + continue; + } + + literal.Append(c); + } + + if (literal.Length > 0) + { + builder.Add(MessageTemplatePart.Literal(literal.ToString())); + } + + parts = builder.ToImmutable(); + return isValid; + } + + private static bool IsBarePlaceholderName(string value) + { + if (value.Length == 0 || !(char.IsLetter(value[0]) || value[0] == '_')) + { + return false; + } + + for (var i = 1; i < value.Length; i++) + { + var c = value[i]; + if (!(char.IsLetterOrDigit(c) || c == '_')) + { + return false; + } + } + + return true; + } + + private static string FormatMessageValue(object? value) + { + return value switch + { + null => string.Empty, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + } + private static bool TryGetMetadataSchemaProperties( AttributeData ruleAttribute, string code, @@ -729,6 +947,55 @@ memberAccess.Expression is not IdentifierNameSyntax identifier || return ToCamelCase(memberAccess.Name.Identifier.ValueText); } + private static string? TryGetExplicitDisplayName( + SemanticModel semanticModel, + InvocationExpressionSyntax checkInvocation, + CancellationToken cancellationToken + ) + { + var symbol = semanticModel.GetSymbolInfo(checkInvocation, cancellationToken).Symbol as IMethodSymbol; + if (symbol is null) + { + return null; + } + + var displayNameParameterIndex = -1; + for (var i = 0; i < symbol.Parameters.Length; i++) + { + if (symbol.Parameters[i].Name == "displayName") + { + displayNameParameterIndex = i; + break; + } + } + + if (displayNameParameterIndex < 0) + { + return null; + } + + for (var i = 0; i < checkInvocation.ArgumentList.Arguments.Count; i++) + { + var argument = checkInvocation.ArgumentList.Arguments[i]; + if (argument.NameColon is not null) + { + if (argument.NameColon.Name.Identifier.ValueText != "displayName") + { + continue; + } + } + else if (i != displayNameParameterIndex) + { + continue; + } + + var constant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + return constant.HasValue ? constant.Value as string : null; + } + + return null; + } + private static string? GetJsonPropertyName(IPropertySymbol propertySymbol) { foreach (var attribute in propertySymbol.GetAttributes()) @@ -753,6 +1020,22 @@ private static string ToCamelCase(string name) return char.ToLowerInvariant(name[0]) + name.Substring(1); } + private readonly struct MessageTemplatePart + { + private MessageTemplatePart(string text, string? placeholder) + { + Text = text; + Placeholder = placeholder; + } + + public string Text { get; } + public string? Placeholder { get; } + + public static MessageTemplatePart Literal(string text) => new (text, null); + + public static MessageTemplatePart PlaceholderValue(string placeholder) => new (string.Empty, placeholder); + } + private static string? ResolveTypedValueTypeName(IMethodSymbol symbol, RuleMetadataShape shape) { if (shape == RuleMetadataShape.Registered) @@ -1004,8 +1287,10 @@ ICollection diagnostics var target = attribute.NamedArguments.FirstOrDefault(static argument => argument.Key == "Target") .Value.Value as string; + var message = attribute.NamedArguments.FirstOrDefault(static argument => argument.Key == "Message") + .Value.Value as string; var metadataValues = GetExampleMetadataValues(attributeArray, code!, diagnostics); - yield return new ExampleHintModel(code!, target, metadataValues); + yield return new ExampleHintModel(code!, target, message, metadataValues); } foreach (var metadataAttribute in attributeArray.Where( diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 6b336d6..386eefc 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -233,18 +233,17 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) { foreach (var rule in model.Rules) { - if (rule.MetadataValues.Any(static metadata => !metadata.HasConstantValue)) - { - continue; - } + var canEmitMetadata = rule.MetadataValues.All(static metadata => metadata.HasConstantValue); writer .Write("builder.WithErrorExample(") .Write(ToStringLiteral(rule.Code)) .Write(", ") - .Write(rule.Target is null ? "null" : ToStringLiteral(rule.Target)); + .Write(rule.Target is null ? "null" : ToStringLiteral(rule.Target)) + .Write(", ") + .Write(rule.Message is null ? "null" : ToStringLiteral(rule.Message)); - if (rule.MetadataValues.Length > 0) + if (canEmitMetadata && rule.MetadataValues.Length > 0) { EmitMetadataDictionary(writer.Write(", "), rule.MetadataValues); } @@ -258,6 +257,7 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) { example.Code, Target = example.Target ?? string.Empty, + Message = example.Message ?? string.Empty, Metadata = string.Join( "\u001F", example.MetadataValues.Select( @@ -274,7 +274,9 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) .Write("builder.WithErrorExample(") .Write(ToStringLiteral(example.Code)) .Write(", ") - .Write(example.Target is null ? "null" : ToStringLiteral(example.Target)); + .Write(example.Target is null ? "null" : ToStringLiteral(example.Target)) + .Write(", ") + .Write(example.Message is null ? "null" : ToStringLiteral(example.Message)); if (example.MetadataValues.Length > 0) { diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs index 064621f..7f06a95 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs @@ -139,6 +139,7 @@ public RuleCallModel( string code, RuleMetadataShape shape, string? target, + string? message, string? typedValueTypeName, ImmutableArray metadataValues, ImmutableArray metadataSchemaProperties @@ -147,6 +148,7 @@ ImmutableArray metadataSchemaProperties Code = code; Shape = shape; Target = target; + Message = message; TypedValueTypeName = typedValueTypeName; MetadataValues = metadataValues; MetadataSchemaProperties = metadataSchemaProperties; @@ -155,6 +157,7 @@ ImmutableArray metadataSchemaProperties public string Code { get; } public RuleMetadataShape Shape { get; } public string? Target { get; } + public string? Message { get; } public string? TypedValueTypeName { get; } public ImmutableArray MetadataValues { get; } public ImmutableArray MetadataSchemaProperties { get; } @@ -211,16 +214,19 @@ internal sealed class ExampleHintModel public ExampleHintModel( string code, string? target, + string? message, ImmutableArray metadataValues ) { Code = code; Target = target; + Message = message; MetadataValues = metadataValues; } public string Code { get; } public string? Target { get; } + public string? Message { get; } public ImmutableArray MetadataValues { get; } } diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs index 0a9f4ab..3fbc662 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs @@ -113,7 +113,12 @@ public static PortableProblemOpenApiBuilder WithGreaterThanOrEqualToError( _ => CreateComparisonSchema(), $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" ); - return AddComparisonExample(configuredBuilder, ValidationErrorCodes.GreaterThanOrEqualTo, target, comparativeValue); + return AddComparisonExample( + configuredBuilder, + ValidationErrorCodes.GreaterThanOrEqualTo, + target, + comparativeValue + ); } /// Documents endpoint-specific GreaterThanOrEqualTo validation error metadata. @@ -128,7 +133,12 @@ public static PortableValidationProblemOpenApiBuilder WithGreaterThanOrEqualToEr _ => CreateComparisonSchema(), $"GreaterThanOrEqualToMetadata<{typeof(T).Name}>" ); - return AddComparisonExample(configuredBuilder, ValidationErrorCodes.GreaterThanOrEqualTo, target, comparativeValue); + return AddComparisonExample( + configuredBuilder, + ValidationErrorCodes.GreaterThanOrEqualTo, + target, + comparativeValue + ); } /// Documents endpoint-specific LessThan validation error metadata. @@ -173,7 +183,12 @@ public static PortableProblemOpenApiBuilder WithLessThanOrEqualToError( _ => CreateComparisonSchema(), $"LessThanOrEqualToMetadata<{typeof(T).Name}>" ); - return AddComparisonExample(configuredBuilder, ValidationErrorCodes.LessThanOrEqualTo, target, comparativeValue); + return AddComparisonExample( + configuredBuilder, + ValidationErrorCodes.LessThanOrEqualTo, + target, + comparativeValue + ); } /// Documents endpoint-specific LessThanOrEqualTo validation error metadata. @@ -188,7 +203,12 @@ public static PortableValidationProblemOpenApiBuilder WithLessThanOrEqualToError _ => CreateComparisonSchema(), $"LessThanOrEqualToMetadata<{typeof(T).Name}>" ); - return AddComparisonExample(configuredBuilder, ValidationErrorCodes.LessThanOrEqualTo, target, comparativeValue); + return AddComparisonExample( + configuredBuilder, + ValidationErrorCodes.LessThanOrEqualTo, + target, + comparativeValue + ); } /// Documents endpoint-specific InRange validation error metadata. @@ -236,7 +256,13 @@ public static PortableProblemOpenApiBuilder WithNotInRangeError( _ => CreateRangeSchema(), $"NotInRangeMetadata<{typeof(T).Name}>" ); - return AddRangeExample(configuredBuilder, ValidationErrorCodes.NotInRange, target, lowerBoundary, upperBoundary); + return AddRangeExample( + configuredBuilder, + ValidationErrorCodes.NotInRange, + target, + lowerBoundary, + upperBoundary + ); } /// Documents endpoint-specific NotInRange validation error metadata. @@ -252,7 +278,13 @@ public static PortableValidationProblemOpenApiBuilder WithNotInRangeError( _ => CreateRangeSchema(), $"NotInRangeMetadata<{typeof(T).Name}>" ); - return AddRangeExample(configuredBuilder, ValidationErrorCodes.NotInRange, target, lowerBoundary, upperBoundary); + return AddRangeExample( + configuredBuilder, + ValidationErrorCodes.NotInRange, + target, + lowerBoundary, + upperBoundary + ); } /// Documents endpoint-specific ExclusiveRange validation error metadata. @@ -268,7 +300,13 @@ public static PortableProblemOpenApiBuilder WithExclusiveRangeError( _ => CreateRangeSchema(), $"ExclusiveRangeMetadata<{typeof(T).Name}>" ); - return AddRangeExample(configuredBuilder, ValidationErrorCodes.ExclusiveRange, target, lowerBoundary, upperBoundary); + return AddRangeExample( + configuredBuilder, + ValidationErrorCodes.ExclusiveRange, + target, + lowerBoundary, + upperBoundary + ); } /// Documents endpoint-specific ExclusiveRange validation error metadata. @@ -284,11 +322,17 @@ public static PortableValidationProblemOpenApiBuilder WithExclusiveRangeError _ => CreateRangeSchema(), $"ExclusiveRangeMetadata<{typeof(T).Name}>" ); - return AddRangeExample(configuredBuilder, ValidationErrorCodes.ExclusiveRange, target, lowerBoundary, upperBoundary); + return AddRangeExample( + configuredBuilder, + ValidationErrorCodes.ExclusiveRange, + target, + lowerBoundary, + upperBoundary + ); } private static OpenApiSchema CreateComparisonSchema() => - new() + new () { Type = JsonSchemaType.Object, Properties = new Dictionary(StringComparer.Ordinal) @@ -299,7 +343,7 @@ private static OpenApiSchema CreateComparisonSchema() => }; private static OpenApiSchema CreateRangeSchema() => - new() + new () { Type = JsonSchemaType.Object, Properties = new Dictionary(StringComparer.Ordinal) @@ -322,7 +366,7 @@ private static PortableProblemOpenApiBuilder AddComparisonExample( ) => target is null ? builder : - builder.WithErrorExample(code, target, CreateComparisonMetadata(comparativeValue)); + builder.WithErrorExample(code, target, null, CreateComparisonMetadata(comparativeValue)); private static PortableValidationProblemOpenApiBuilder AddComparisonExample( PortableValidationProblemOpenApiBuilder builder, @@ -332,7 +376,7 @@ private static PortableValidationProblemOpenApiBuilder AddComparisonExample( ) => target is null ? builder : - builder.WithErrorExample(code, target, CreateComparisonMetadata(comparativeValue)); + builder.WithErrorExample(code, target, null, CreateComparisonMetadata(comparativeValue)); private static PortableProblemOpenApiBuilder AddRangeExample( PortableProblemOpenApiBuilder builder, @@ -343,7 +387,7 @@ private static PortableProblemOpenApiBuilder AddRangeExample( ) => target is null ? builder : - builder.WithErrorExample(code, target, CreateRangeMetadata(lowerBoundary, upperBoundary)); + builder.WithErrorExample(code, target, null, CreateRangeMetadata(lowerBoundary, upperBoundary)); private static PortableValidationProblemOpenApiBuilder AddRangeExample( PortableValidationProblemOpenApiBuilder builder, @@ -354,7 +398,7 @@ private static PortableValidationProblemOpenApiBuilder AddRangeExample( ) => target is null ? builder : - builder.WithErrorExample(code, target, CreateRangeMetadata(lowerBoundary, upperBoundary)); + builder.WithErrorExample(code, target, null, CreateRangeMetadata(lowerBoundary, upperBoundary)); private static IReadOnlyDictionary CreateComparisonMetadata(T? comparativeValue) => new Dictionary(StringComparer.Ordinal) @@ -375,7 +419,9 @@ private static PortableProblemOpenApiBuilder EnsureBuilder(PortableProblemOpenAp return builder; } - private static PortableValidationProblemOpenApiBuilder EnsureBuilder(PortableValidationProblemOpenApiBuilder builder) + private static PortableValidationProblemOpenApiBuilder EnsureBuilder( + PortableValidationProblemOpenApiBuilder builder + ) { ArgumentNullException.ThrowIfNull(builder); return builder; diff --git a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs index b2a7727..b5a43e0 100644 --- a/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs +++ b/src/Light.PortableResults.Validation.OpenApi/PortableValidationOpenApiExampleHintAttribute.cs @@ -26,4 +26,9 @@ public PortableValidationOpenApiExampleHintAttribute(string code) /// Gets or sets the optional validation target used in the generated example entry. /// public string? Target { get; set; } + + /// + /// Gets or sets the optional validation message used in the generated example entry. + /// + public string? Message { get; set; } } diff --git a/src/Light.PortableResults.Validation/Checks.Comparable.cs b/src/Light.PortableResults.Validation/Checks.Comparable.cs index 82627c1..9f9ef27 100644 --- a/src/Light.PortableResults.Validation/Checks.Comparable.cs +++ b/src/Light.PortableResults.Validation/Checks.Comparable.cs @@ -31,6 +31,7 @@ public static partial class Checks /// before this assertion. /// [ValidationRule(ValidationErrorCodes.GreaterThan, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMessage("{displayName} must be greater than {comparativeValue}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsGreaterThan( this Check check, @@ -137,6 +138,7 @@ public static Check IsGreaterThan( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.GreaterThanOrEqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMessage("{displayName} must be greater than or equal to {comparativeValue}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsGreaterThanOrEqualTo( this Check check, @@ -243,6 +245,7 @@ public static Check IsGreaterThanOrEqualTo( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.LessThan, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMessage("{displayName} must be less than {comparativeValue}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsLessThan( this Check check, @@ -349,6 +352,7 @@ public static Check IsLessThan( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.LessThanOrEqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMessage("{displayName} must be less than or equal to {comparativeValue}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsLessThanOrEqualTo( this Check check, @@ -458,6 +462,7 @@ public static Check IsLessThanOrEqualTo( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.InRange, ValidationRuleMetadataShape.TypedRange)] + [ValidationRuleMessage("{displayName} must be between {lowerBoundary} and {upperBoundary}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] [ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] public static Check IsInRange( @@ -577,6 +582,7 @@ public static Check IsInRange( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.NotInRange, ValidationRuleMetadataShape.TypedRange)] + [ValidationRuleMessage("{displayName} must not be between {lowerBoundary} and {upperBoundary}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] [ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] public static Check IsNotInRange( @@ -698,6 +704,7 @@ public static Check IsNotInRange( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.ExclusiveRange, ValidationRuleMetadataShape.TypedRange)] + [ValidationRuleMessage("{displayName} must be between {lowerBoundary} and {upperBoundary} (exclusive)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.LowerBoundary, nameof(lowerBoundary))] [ValidationRuleMetadata(ValidationErrorMetadataKeys.UpperBoundary, nameof(upperBoundary))] public static Check IsInExclusiveRange( diff --git a/src/Light.PortableResults.Validation/Checks.Count.cs b/src/Light.PortableResults.Validation/Checks.Count.cs index e79b896..1111583 100644 --- a/src/Light.PortableResults.Validation/Checks.Count.cs +++ b/src/Light.PortableResults.Validation/Checks.Count.cs @@ -29,6 +29,7 @@ public static partial class Checks /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.Count)] + [ValidationRuleMessage("{displayName} must contain exactly {expectedCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedCount, nameof(expectedCount))] public static Check HasCount( this Check check, @@ -123,6 +124,7 @@ public static partial class Checks /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.MinCount)] + [ValidationRuleMessage("{displayName} must contain at least {minCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinCount, nameof(minCount))] public static Check HasMinCount( this Check check, @@ -217,6 +219,7 @@ public static partial class Checks /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.MaxCount)] + [ValidationRuleMessage("{displayName} must contain at most {maxCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxCount, nameof(maxCount))] public static Check HasMaxCount( this Check check, @@ -311,6 +314,7 @@ public static partial class Checks /// before this assertion. /// [ValidationRule(ValidationErrorCodes.Count)] + [ValidationRuleMessage("{displayName} must contain exactly {expectedCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedCount, nameof(expectedCount))] public static Check HasCount( this Check check, @@ -407,6 +411,7 @@ public static Check HasCount( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.MinCount)] + [ValidationRuleMessage("{displayName} must contain at least {minCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinCount, nameof(minCount))] public static Check HasMinCount( this Check check, @@ -503,6 +508,7 @@ public static Check HasMinCount( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.MaxCount)] + [ValidationRuleMessage("{displayName} must contain at most {maxCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxCount, nameof(maxCount))] public static Check HasMaxCount( this Check check, @@ -595,6 +601,7 @@ public static Check HasMaxCount( /// Thrown when is negative. /// [ValidationRule(ValidationErrorCodes.Count)] + [ValidationRuleMessage("{displayName} must contain exactly {expectedCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ExpectedCount, nameof(expectedCount))] public static Check> HasCount( this Check> check, @@ -669,6 +676,7 @@ public static Check> HasCount( /// Thrown when is negative. /// [ValidationRule(ValidationErrorCodes.MinCount)] + [ValidationRuleMessage("{displayName} must contain at least {minCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinCount, nameof(minCount))] public static Check> HasMinCount( this Check> check, @@ -743,6 +751,7 @@ public static Check> HasMinCount( /// Thrown when is negative. /// [ValidationRule(ValidationErrorCodes.MaxCount)] + [ValidationRuleMessage("{displayName} must contain at most {maxCount} item(s)")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxCount, nameof(maxCount))] public static Check> HasMaxCount( this Check> check, diff --git a/src/Light.PortableResults.Validation/Checks.Empty.cs b/src/Light.PortableResults.Validation/Checks.Empty.cs index 8676762..ad4cc03 100644 --- a/src/Light.PortableResults.Validation/Checks.Empty.cs +++ b/src/Light.PortableResults.Validation/Checks.Empty.cs @@ -25,6 +25,7 @@ public static partial class Checks /// should also be rejected, use instead. /// [ValidationRule(ValidationErrorCodes.Empty)] + [ValidationRuleMessage("{displayName} must be empty")] public static Check IsEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || string.IsNullOrEmpty(check.Value) ? check : @@ -86,6 +87,7 @@ public static partial class Checks /// to also reject whitespace. /// [ValidationRule(ValidationErrorCodes.NotEmpty)] + [ValidationRuleMessage("{displayName} must not be empty")] public static Check IsNotEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || !string.IsNullOrEmpty(check.Value) ? check : @@ -142,6 +144,7 @@ public static partial class Checks /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.Empty)] + [ValidationRuleMessage("{displayName} must be empty")] public static Check IsEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || check.Value == Guid.Empty ? check : @@ -193,6 +196,7 @@ public static Check IsEmpty( /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.NotEmpty)] + [ValidationRuleMessage("{displayName} must not be empty")] public static Check IsNotEmpty(this Check check, bool shortCircuitOnError = false) => check.IsShortCircuited || check.Value != Guid.Empty ? check : @@ -248,6 +252,7 @@ public static Check IsNotEmpty( /// A collection is treated as empty and passes without error. /// [ValidationRule(ValidationErrorCodes.Empty)] + [ValidationRuleMessage("{displayName} must be empty")] public static Check IsEmpty( this Check check, bool shortCircuitOnError = false @@ -334,6 +339,7 @@ public static Check IsEmpty( /// A collection triggers a validation error (treated as absent/empty). /// [ValidationRule(ValidationErrorCodes.NotEmpty)] + [ValidationRuleMessage("{displayName} must not be empty")] public static Check IsNotEmpty( this Check check, bool shortCircuitOnError = false @@ -417,6 +423,7 @@ public static Check IsNotEmpty( /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.Empty)] + [ValidationRuleMessage("{displayName} must be empty")] public static Check> IsEmpty( this Check> check, bool shortCircuitOnError = false @@ -473,6 +480,7 @@ public static Check> IsEmpty( /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.NotEmpty)] + [ValidationRuleMessage("{displayName} must not be empty")] public static Check> IsNotEmpty( this Check> check, bool shortCircuitOnError = false diff --git a/src/Light.PortableResults.Validation/Checks.Enums.cs b/src/Light.PortableResults.Validation/Checks.Enums.cs index 6fdedab..bc7fffb 100644 --- a/src/Light.PortableResults.Validation/Checks.Enums.cs +++ b/src/Light.PortableResults.Validation/Checks.Enums.cs @@ -23,6 +23,7 @@ public static partial class Checks /// declared members of are considered invalid. /// [ValidationRule(ValidationErrorCodes.Enum)] + [ValidationRuleMessage("{displayName} must be a defined enum value")] public static Check IsInEnum(this Check check, bool shortCircuitOnError = false) where TEnum : struct, Enum { @@ -98,6 +99,7 @@ public static Check IsInEnum( /// before this assertion. /// [ValidationRule(ValidationErrorCodes.Enum)] + [ValidationRuleMessage("{displayName} must be a defined enum value")] public static Check IsInEnum(this Check check, bool shortCircuitOnError = false) where TEnum : struct, Enum { @@ -107,7 +109,7 @@ public static Check IsInEnum( } var value = GetRequiredValue(check.Value, nameof(IsInEnum)); - if (Enum.IsDefined(typeof(TEnum), value)) + if (Enum.IsDefined(typeof(TEnum), value!)) { return check; } @@ -157,7 +159,7 @@ public static Check IsInEnum( } var value = GetRequiredValue(check.Value, nameof(IsInEnum)); - if (Enum.IsDefined(typeof(TEnum), value)) + if (Enum.IsDefined(typeof(TEnum), value!)) { return check; } @@ -192,6 +194,7 @@ public static Check IsInEnum( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.EnumName)] + [ValidationRuleMessage("{displayName} must be a valid enum name")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.IgnoreCase, nameof(ignoreCase))] public static Check IsEnumName( this Check check, diff --git a/src/Light.PortableResults.Validation/Checks.Equality.cs b/src/Light.PortableResults.Validation/Checks.Equality.cs index 15f3dd5..611091e 100644 --- a/src/Light.PortableResults.Validation/Checks.Equality.cs +++ b/src/Light.PortableResults.Validation/Checks.Equality.cs @@ -21,6 +21,7 @@ public static partial class Checks /// The current check for fluent chaining. /// Uses for the comparison. [ValidationRule(ValidationErrorCodes.EqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMessage("{displayName} must be equal to {comparativeValue}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsEqualTo( this Check check, @@ -162,6 +163,7 @@ public static Check IsEqualTo( /// The current check for fluent chaining. /// Uses for the comparison. [ValidationRule(ValidationErrorCodes.NotEqualTo, ValidationRuleMetadataShape.TypedComparison)] + [ValidationRuleMessage("{displayName} must not be equal to {comparativeValue}")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.ComparativeValue, nameof(comparativeValue))] public static Check IsNotEqualTo( this Check check, diff --git a/src/Light.PortableResults.Validation/Checks.Null.cs b/src/Light.PortableResults.Validation/Checks.Null.cs index 0e6e140..9fa3a2e 100644 --- a/src/Light.PortableResults.Validation/Checks.Null.cs +++ b/src/Light.PortableResults.Validation/Checks.Null.cs @@ -20,6 +20,7 @@ public static partial class Checks /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.NotNull)] + [ValidationRuleMessage("{displayName} must not be null")] public static Check IsNotNull(this Check check, bool shortCircuitOnError = true) => check.IsShortCircuited || !check.IsValueNull ? check : @@ -74,6 +75,7 @@ public static Check IsNotNull( /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.Null)] + [ValidationRuleMessage("{displayName} must be null")] public static Check IsNull(this Check check, bool shortCircuitOnError = true) { if (check.IsShortCircuited || check.IsValueNull) diff --git a/src/Light.PortableResults.Validation/Checks.Strings.cs b/src/Light.PortableResults.Validation/Checks.Strings.cs index 4f08d29..b963a69 100644 --- a/src/Light.PortableResults.Validation/Checks.Strings.cs +++ b/src/Light.PortableResults.Validation/Checks.Strings.cs @@ -21,6 +21,7 @@ public static partial class Checks /// /// The current check for fluent chaining. [ValidationRule(ValidationErrorCodes.NotNullOrWhiteSpace)] + [ValidationRuleMessage("{displayName} must not be empty or whitespace")] public static Check IsNotNullOrWhiteSpace(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) @@ -102,6 +103,7 @@ public static Check IsNotNullOrWhiteSpace( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.MinLength)] + [ValidationRuleMessage("{displayName} must be at least {minLength} characters long")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinLength, nameof(minLength))] public static Check HasMinLength( this Check check, @@ -196,6 +198,7 @@ public static Check HasMinLength( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.MaxLength)] + [ValidationRuleMessage("{displayName} must be at most {maxLength} characters long")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxLength, nameof(maxLength))] public static Check HasMaxLength( this Check check, @@ -299,6 +302,7 @@ public static Check HasMinLength( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.LengthInRange)] + [ValidationRuleMessage("{displayName} must be between {minLength} and {maxLength} characters long")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MinLength, nameof(minLength))] [ValidationRuleMetadata(ValidationErrorMetadataKeys.MaxLength, nameof(maxLength))] public static Check HasLengthIn( @@ -527,6 +531,7 @@ public static Check Matches( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.Pattern)] + [ValidationRuleMessage("{displayName} has an invalid format")] [ValidationRuleMetadata(ValidationErrorMetadataKeys.Pattern, nameof(pattern))] [ValidationRuleMetadata(ValidationErrorMetadataKeys.RegexOptions, nameof(options))] public static Check Matches( @@ -643,6 +648,7 @@ public static Check Matches( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.Email)] + [ValidationRuleMessage("{displayName} must be an email address")] public static Check IsEmail(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) @@ -726,6 +732,7 @@ public static Check IsEmail( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.DigitsOnly)] + [ValidationRuleMessage("{displayName} must contain only digits")] public static Check ContainsOnlyDigits(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) @@ -807,6 +814,7 @@ public static Check ContainsOnlyDigits( /// so this only occurs when using a no-op normalizer. /// [ValidationRule(ValidationErrorCodes.LettersAndDigitsOnly)] + [ValidationRuleMessage("{displayName} must contain only letters and digits")] public static Check ContainsOnlyLettersAndDigits(this Check check, bool shortCircuitOnError = false) { if (check.IsShortCircuited) diff --git a/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs b/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs index 6d6bd5c..614ce98 100644 --- a/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs +++ b/src/Light.PortableResults.Validation/Definitions/ValidationRuleAttributes.cs @@ -129,6 +129,27 @@ public ValidationRuleMetadataAttribute(string metadataKey, string sourceArgument public Type? ConstantTypeValue { get; set; } } +/// +/// Describes a compile-time example message template for a validation rule. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ValidationRuleMessageAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + public ValidationRuleMessageAttribute(string template) + { + ValidationAttributeGuard.EnsureNotNullOrWhiteSpace(template, nameof(template)); + Template = template; + } + + /// + /// Gets the compile-time example message template. + /// + public string Template { get; } +} + /// /// Marks a validation error definition as having a stable documentable error contract. /// diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index ec19de2..190bb63 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -81,6 +81,46 @@ public void ProducesPortableValidationProblem_ShouldAccumulateRouteMetadata() attribute.HasFormatOverride.Should().BeTrue(); } + [Fact] + public void ErrorExampleEntries_ShouldIncludeMessageInEqualityAndHashing() + { + var first = new PortableOpenApiErrorExampleEntry("NotEmpty", "name", "name must not be empty", null); + var same = new PortableOpenApiErrorExampleEntry("NotEmpty", "name", "name must not be empty", null); + var differentMessage = new PortableOpenApiErrorExampleEntry("NotEmpty", "name", "custom message", null); + + first.Should().Be(same); + first.GetHashCode().Should().Be(same.GetHashCode()); + first.Should().NotBe(differentMessage); + } + + [Fact] + public void ProducesPortableValidationProblem_ShouldAccumulateMessageAwareExamples() + { + var metadata = new Dictionary(StringComparer.Ordinal) { ["minLength"] = 3 }; + var attribute = GetMetadata( + builder => + builder.ProducesPortableValidationProblem( + configure: x => x.WithErrorExample( + "MinLength", + "name", + "name must be at least 3 characters long", + metadata + ) + ), + static () => TypedResults.Problem() + ); + + attribute.ErrorExamples.Should() + .ContainSingle() + .Which.Should() + .Be(new PortableOpenApiErrorExampleEntry( + "MinLength", + "name", + "name must be at least 3 characters long", + metadata + )); + } + [Fact] public void ProducesPortableSuccessResponse_ShouldAccumulateRouteMetadata() { diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index cd491b5..b25d245 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Text.Json.Nodes; using System.Threading.Tasks; using FluentAssertions; using Light.PortableResults.AspNetCore.MinimalApis; @@ -477,6 +478,63 @@ await GetOpenApiDocumentAsync(app), ((OpenApiSchema) mediaType.Schema!).AnyOf.Should().HaveCount(2); } + [Fact] + public async Task Transformer_ShouldUseConfiguredMessagesInRichValidationExamples() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/validation/message-rich", static () => TypedResults.Problem()) + .WithName("MessageRich") + .ProducesPortableValidationProblem( + configure: builder => builder + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorExample("NotEmpty", "name", "name must not be empty") + .WithErrorExample("Unknown", "description") + ); + } + ); + + var body = GetValidationProblemExample( + await GetOpenApiDocumentAsync(app), + "/minimal/validation/message-rich" + ); + var errors = body["errors"].Should().BeOfType().Subject; + + errors[0]!["message"]!.ToString().Should().Be("name must not be empty"); + errors[1]!["message"]!.ToString().Should().Be("Validation failed."); + } + + [Fact] + public async Task Transformer_ShouldUseConfiguredMessagesInAspNetCoreCompatibleValidationExamples() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/validation/message-aspnet", static () => TypedResults.Problem()) + .WithName("MessageAspNet") + .ProducesPortableValidationProblem( + configure: builder => builder + .UseFormat(ValidationProblemSerializationFormat.AspNetCoreCompatible) + .WithErrorExample("NotEmpty", "name", "name must not be empty") + .WithErrorExample("Unknown", "name") + ); + } + ); + + var body = GetValidationProblemExample( + await GetOpenApiDocumentAsync(app), + "/minimal/validation/message-aspnet" + ); + var messages = body["errors"]!["name"].Should().BeOfType().Subject; + + messages.Select(static message => message!.ToString()) + .Should() + .Equal("name must not be empty", "Validation failed."); + } + [Fact] public async Task Transformer_ShouldMaterializeReferencedResponsesBeforeWritingContent() { @@ -870,6 +928,14 @@ int statusCode return (OpenApiResponse) operation.Responses![statusCode.ToString(CultureInfo.InvariantCulture)]; } + private static JsonObject GetValidationProblemExample(OpenApiDocument document, string path) + { + var response = GetResponse(document, path, HttpMethod.Get, StatusCodes.Status400BadRequest); + var mediaType = response.Content!["application/problem+json"]; + var example = (OpenApiExample) mediaType.Examples!["ValidationProblem"]; + return example.Value.Should().BeOfType().Subject; + } + private static OpenApiSchema GetSchemaComponent(OpenApiDocument document, string schemaId) { return (OpenApiSchema) document.Components!.Schemas![schemaId]; diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs index 1ab7d04..2b7aa4d 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs @@ -61,7 +61,11 @@ RatingDto dto source.Should().Contain("partial class RatingValidator : IPortableValidationOpenApiContract"); source.Should().Contain("builder.WithErrorCodes(\"LengthInRange\", \"NotEmpty\");"); source.Should().Contain("builder.WithInRangeError();"); - source.Should().Contain("builder.WithErrorExample(\"NotEmpty\", \"id\");"); + source.Should().Contain("builder.WithErrorExample(\"NotEmpty\", \"id\", \"id must not be empty\");"); + source.Should().Contain( + "builder.WithErrorExample(\"LengthInRange\", \"comment\", \"comment must be between 10 and 1000 characters long\"" + ); + source.Should().Contain("builder.WithErrorExample(\"InRange\", \"rating\", \"rating must be between 1 and 5\""); source.Should().Contain("[\"minLength\"] = 10"); source.Should().Contain("[\"upperBoundary\"] = 5"); } @@ -182,7 +186,7 @@ in ValidationErrorMessageContext context source.Should().Contain("builder.WithErrorMetadata(\"DivisibleBy\", _ => new OpenApiSchema"); source.Should().Contain("[\"divisor\"] = PortableOpenApiSchemaTypeMapper.Map()"); source.Should().Contain( - "builder.WithErrorExample(\"DivisibleBy\", null, new Dictionary(StringComparer.Ordinal) { [\"divisor\"] = 3 });" + "builder.WithErrorExample(\"DivisibleBy\", null, null, new Dictionary(StringComparer.Ordinal) { [\"divisor\"] = 3 });" ); } @@ -253,7 +257,7 @@ int value source.Should().Contain("[\"lowerBoundary\"] = PortableOpenApiSchemaTypeMapper.Map()"); source.Should().Contain("[\"upperBoundary\"] = PortableOpenApiSchemaTypeMapper.Map()"); source.Should().Contain( - "builder.WithErrorExample(\"RatingTooLow\", \"rating\", new Dictionary(StringComparer.Ordinal) { [\"lowerBoundary\"] = 1, [\"upperBoundary\"] = 5 });" + "builder.WithErrorExample(\"RatingTooLow\", \"rating\", null, new Dictionary(StringComparer.Ordinal) { [\"lowerBoundary\"] = 1, [\"upperBoundary\"] = 5 });" ); source.Should().NotContain("builder.AllowUnknownErrorCodes();"); } @@ -287,7 +291,208 @@ string value .BeEmpty(); var source = result.GeneratedSources.Single(); source.Should().Contain("builder.WithErrorCodes(\"CustomCode\");"); - source.Should().Contain("builder.WithErrorExample(\"CustomCode\", \"name\");"); + source.Should().Contain("builder.WithErrorExample(\"CustomCode\", \"name\", null);"); + } + + [Fact] + public void Generator_ShouldEmitExplicitDisplayNameAndHintMessages() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + [PortableValidationOpenApiExampleHint("Opaque", Target = "movieId", Message = "movieId has already been rated")] + public sealed partial class MessageHintValidator : Validator + { + public MessageHintValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieDto dto + ) + { + context.Check(dto.Name, displayName: "Movie name").IsNotEmpty(); + return checkpoint.ToValidatedValue(dto); + } + } + + public sealed class MovieDto + { + public string Name { get; init; } = ""; + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain( + "builder.WithErrorExample(\"NotEmpty\", \"name\", \"Movie name must not be empty\");" + ); + source.Should().Contain( + "builder.WithErrorExample(\"Opaque\", \"movieId\", \"movieId has already been rated\");" + ); + } + + [Fact] + public void Generator_ShouldOmitMessageWhenTemplateValuesAreNotConstant() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class NonConstantMessageValidator : Validator + { + public NonConstantMessageValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieDto dto + ) + { + var minimum = dto.MinimumLength; + context.Check(dto.Name).HasMinLength(minimum); + return checkpoint.ToValidatedValue(dto); + } + } + + public sealed class MovieDto + { + public string Name { get; init; } = ""; + public int MinimumLength { get; init; } + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var source = result.GeneratedSources.Single(); + source.Should().Contain("builder.WithErrorCodes(\"MinLength\");"); + source.Should().Contain("builder.WithErrorExample(\"MinLength\", \"name\", null);"); + source.Should().NotContain("name must be at least"); + } + + [Fact] + public void Generator_ShouldEmitAnnotatedCustomRuleMessagesWithEscapedBraces() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.Definitions; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class CustomMessageValidator : Validator + { + public CustomMessageValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieDto dto + ) + { + context.Check(dto.Rating).IsMultipleOf(5); + return checkpoint.ToValidatedValue(dto); + } + } + + public static class CustomChecks + { + [ValidationRule("MultipleOf")] + [ValidationRuleMetadata("divisor", "divisor")] + [ValidationRuleMessage("{displayName} must be a multiple of {divisor}; literal braces: {{ok}}")] + public static Check IsMultipleOf(this Check check, int divisor) => check; + } + + public sealed class MovieDto + { + public int Rating { get; init; } + } + """ + ); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.GeneratedSources.Single().Should().Contain( + "builder.WithErrorExample(\"MultipleOf\", \"rating\", \"rating must be a multiple of 5; literal braces: {ok}\"" + ); + } + + [Fact] + public void Generator_ShouldReportDistinctDiagnosticsForMessageTemplateProblems() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.Definitions; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BadTemplateValidator : Validator + { + public BadTemplateValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + int value + ) + { + context.Check(value).HasUnknownPlaceholder(1); + context.Check(value).HasMalformedTemplate(1); + context.Check(value).HasWhitespaceTemplate(1); + context.Check(value).HasUnmatchedCloseBraceTemplate(1); + return checkpoint.ToValidatedValue(value); + } + } + + public static class BadTemplateChecks + { + [ValidationRule("Unknown")] + [ValidationRuleMetadata("known", "value")] + [ValidationRuleMessage("{displayName} {missing}")] + public static Check HasUnknownPlaceholder(this Check check, int value) => check; + + [ValidationRule("Malformed")] + [ValidationRuleMetadata("known", "value")] + [ValidationRuleMessage("{displayName")] + public static Check HasMalformedTemplate(this Check check, int value) => check; + + [ValidationRule("Whitespace")] + [ValidationRuleMetadata("known", "value")] + [ValidationRuleMessage("{ displayName }")] + public static Check HasWhitespaceTemplate(this Check check, int value) => check; + + [ValidationRule("UnmatchedCloseBrace")] + [ValidationRuleMetadata("known", "value")] + [ValidationRuleMessage("{displayName}}")] + public static Check HasUnmatchedCloseBraceTemplate(this Check check, int value) => check; + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id) + .Should() + .Contain(["LPRSG0013", "LPRSG0014"]); + var source = result.GeneratedSources.Single(); + source.Should().Contain("builder.WithErrorExample(\"Unknown\", null, null"); + source.Should().Contain("builder.WithErrorExample(\"Malformed\", null, null"); + source.Should().Contain("builder.WithErrorExample(\"Whitespace\", null, null"); + source.Should().Contain("builder.WithErrorExample(\"UnmatchedCloseBrace\", null, null"); } [Fact] @@ -326,7 +531,7 @@ string value .BeEmpty(); var source = result.GeneratedSources.Single(); source.Should().Contain("builder.WithErrorMetadata(\"CustomCode\");"); - source.Should().Contain("builder.WithErrorExample(\"CustomCode\", \"name\");"); + source.Should().Contain("builder.WithErrorExample(\"CustomCode\", \"name\", null);"); source.Should().NotContain("builder.WithErrorCodes(\"CustomCode\");"); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs index a4296fc..a475266 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs @@ -64,6 +64,10 @@ public async Task ProducesPortableValidationProblemFor_ShouldApplyGeneratedSchem exampleErrors.ToJsonString().Should().Contain("\"upperBoundary\":5"); exampleErrors.ToJsonString().Should().Contain("\"minLength\":10"); exampleErrors.ToJsonString().Should().Contain("\"maxLength\":1000"); + exampleErrors.ToJsonString().Should().Contain("\"message\":\"id must not be empty\""); + exampleErrors.ToJsonString().Should() + .Contain("\"message\":\"comment must be between 10 and 1000 characters long\""); + exampleErrors.ToJsonString().Should().Contain("\"message\":\"rating must be between 1 and 5\""); var genericProblemResponse = (OpenApiResponse) operation.Responses![StatusCodes.Status500InternalServerError.ToString()]; genericProblemResponse.Content!["application/problem+json"].Examples.Should().BeNullOrEmpty(); From 1507dcb7034d32fed8fc554f4d4b42d25e0d6a0d Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Thu, 21 May 2026 21:26:18 +0200 Subject: [PATCH 14/17] chore: add plan deviations for OpenAPI source generation Signed-off-by: Kenny Pflug --- Light.PortableResults.slnx | 1 + ai-plans/0043-3-plan-deviations.md | 92 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 ai-plans/0043-3-plan-deviations.md diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index d07e7e4..2af9fb1 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -59,6 +59,7 @@ + diff --git a/ai-plans/0043-3-plan-deviations.md b/ai-plans/0043-3-plan-deviations.md new file mode 100644 index 0000000..c6777db --- /dev/null +++ b/ai-plans/0043-3-plan-deviations.md @@ -0,0 +1,92 @@ +# OpenAPI Source Generation Plan Deviations + +This document summarizes how the current implementation evolved from the original plan in `0043-0-openapi-source-generation.md`. It is not a new implementation plan and does not define new acceptance criteria. + +## Summary + +The original direction stayed intact: validation OpenAPI metadata is generated from synchronous validators, emitted through a static contract, and consumed by Minimal API endpoint metadata without reflection. The implementation added two follow-up capabilities that were not fully covered by `0043-0`: explicit documentation hints for opaque validation paths, and representative per-error example messages. + +Most boundaries from the original plan still hold. The generator remains Minimal API-focused, supports direct `Validator` and `Validator` inheritance only, skips nested control-flow checks with diagnostics, treats `Must(...)`, `Custom(...)`, and `ErrorOverrides` as opaque unless documented explicitly, and keeps generated code NativeAOT-safe. + +## Runtime API Deviations + +The generated contract target is the public static-abstract interface `IPortableValidationOpenApiContract` in `Light.PortableResults.Validation.OpenApi`. Generated validators implement this interface and expose `ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)`, matching the original reflection-free design. + +The Minimal API bridge is implemented as `ProducesPortableValidationProblemFor(...)` in `Light.PortableResults.Validation.OpenApi`. It delegates to the existing ASP.NET Core OpenAPI builder, first applying the generated validator contract and then invoking the caller's `configure` callback. This preserves the intended ordering: generated metadata is the baseline, endpoint-local manual configuration can override or extend it. + +`PortableOpenApiErrorExampleEntry` and `WithErrorExample(...)` were extended beyond the original plan to include a nullable per-error `message` before metadata. The OpenAPI transformer now uses that message for both rich validation problem examples and ASP.NET Core-compatible validation problem examples, falling back to `"Validation failed."` when no message is supplied. + +## Hint Model Deviations + +The first plan treated explicit emitted-error hints as an escape hatch but did not fully specify their public API. The implementation made this a first-class feature: + +- `PortableValidationOpenApiErrorHintAttribute` documents a known error code and can optionally point to a metadata type. +- `PortableValidationOpenApiErrorMetadataPropertyAttribute` documents inline metadata schema properties when a dedicated metadata type would be unnecessary or awkward. +- `PortableValidationOpenApiExampleHintAttribute` documents response-example entries for opaque paths and can include `Target` and `Message`. +- `PortableValidationOpenApiExampleMetadataAttribute` supplies compile-time constant metadata values for matching example hints. + +Hints can be placed on the validator class or directly on `PerformValidation`. They compose with inferred rules, are deduplicated when compatible, and produce diagnostics when schema contracts conflict. Example-only hints also document the code as a code-only schema entry, avoiding a redundant error hint for common opaque paths. + +`AllowUnknownErrorCodes` is available on `GeneratePortableValidationOpenApiAttribute` as an explicit opt-in. Hints do not imply it. This is a sharper separation than the original plan text: hints document known contracts, while `AllowUnknownErrorCodes` keeps the response schema non-exhaustive for additional codes that are not enumerable at build time. + +## Message Deviations + +The implementation added `ValidationRuleMessageAttribute` in `Light.PortableResults.Validation.Definitions`. Built-in validation check methods now carry compile-time message templates where the default framework message can be represented statically. + +The generator substitutes `{displayName}` and metadata placeholders such as `{minLength}`, `{maxLength}`, `{lowerBoundary}`, `{upperBoundary}`, and `{comparativeValue}` when all required values are known at compile time. The display name is resolved from an explicit constant `displayName` argument to `ValidationContext.Check(...)`, then from the inferred JSON-style target, and otherwise omitted. + +If a template is valid but a value is not statically known, the generator still emits the schema and example entry but leaves the message as `null`, letting the transformer use the fallback message. Invalid templates produce warning diagnostics: + +- `LPRSG0013` for unknown placeholders. +- `LPRSG0014` for malformed brace sequences or unsupported placeholder syntax. + +This is a deliberate documentation feature only. Generated messages are representative defaults and are not intended to exactly model runtime customization, localization, culture-specific formatting, display-name customization, or `ErrorOverrides.Message`. + +## Analysis and Generation Deviations + +The source generator is implemented as an `IIncrementalGenerator` using `ForAttributeWithMetadataName(...)`, as planned. The thin generator adapter calls the public `ValidatorOpenApiAnalyzer.Analyze(...)` method, which returns a `ValidatorOpenApiAnalysis` containing diagnostics, hint name, and source. The implementation therefore made the analysis directly testable without a full generator-driver flow. + +The generator does not reference runtime projects as normal assembly references. It resolves contracts by metadata name through `KnownTypeNames`, while generated source depends on the runtime packages in the consuming application. + +Generated source uses a deterministic controlled using block and emits builder calls rather than runtime reflection. It now emits: + +- grouped `WithErrorCodes(...)` calls for registered no-metadata or globally registered contracts; +- typed helper calls such as `WithInRangeError()` for comparison and range contracts; +- inline `WithErrorMetadata(...)` calls for annotated custom rules and inline hint metadata; +- `WithErrorExample(...)` calls containing code, target, optional representative message, and constant metadata values where available; +- `AllowUnknownErrorCodes()` only when explicitly requested. + +The implementation also chose direct in-memory Roslyn generator tests instead of introducing snapshot infrastructure such as `Verify.SourceGenerators`. This still covers emitted source, diagnostics, and generated OpenAPI document behavior while keeping the test stack smaller. + +## Validation Rule Annotation Deviations + +The core validation layer gained source-generator-facing annotations in `Light.PortableResults.Validation.Definitions`: + +- `ValidationRuleAttribute` +- `ValidationRuleMetadataAttribute` +- `ValidationRuleMessageAttribute` +- `ValidationErrorContractAttribute` +- `ValidationErrorMetadataContractAttribute` + +These remain OpenAPI-agnostic and use source-generator-friendly attribute shapes only. Built-in checks were annotated rather than hard-coded into the generator. The comparative range rename from `IsInBetween` / `IsNotInBetween` to `IsInRange` / `IsNotInRange` was applied, aligning method names with `ValidationErrorCodes.InRange` and `ValidationErrorCodes.NotInRange`. + +One practical addition beyond the first plan is inline metadata schema generation for user-defined rules and hints. When an annotated rule references a validation error contract, the generator can emit an endpoint-local schema with `PortableOpenApiSchemaTypeMapper.Map()` instead of requiring a pre-registered metadata type for every custom case. + +## Documentation, Sample, and Tests + +The `NativeAotMovieRating` sample now uses `[GeneratePortableValidationOpenApi]` on `NewMovieRatingValidator` and `ProducesPortableValidationProblemFor(...)` on the endpoint. Manual validation error-code configuration was removed from the sample endpoint, with endpoint-local format configuration kept in the callback. + +The README now documents the generator opt-in model, supported validator shapes, top-level-only analysis boundary, explicit hints, example metadata, representative messages, and the distinction between hints and `AllowUnknownErrorCodes()`. + +Tests were added across the source-generation and OpenAPI projects for generator output, diagnostics, custom annotated rules, hints, example metadata, messages, builder behavior, document transformation, and generated OpenAPI integration. The package layout also bundles the generator into `Light.PortableResults.Validation.OpenApi` as an analyzer asset under `analyzers/dotnet/cs`, matching the original distribution intent. + +## Remaining Boundaries + +The following limitations remain intentional after the deviations: + +- MVC integration is still outside this implementation. +- Async validators remain unsupported. +- Indirect/custom validator base classes remain unsupported. +- Nested checks inside control flow remain skipped rather than analyzed. +- Complex target inference, child validators, automatic source-null errors before `PerformValidation`, and runtime-customized validation messages remain outside the generator's static analysis scope. +- Opaque delegate or imperative validation remains documentable only through explicit hints or endpoint-level manual configuration. From e6c0973c1acdbf602b064eb5f6d46de40bf6f5f1 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 22 May 2026 07:17:42 +0200 Subject: [PATCH 15/17] test: increase test coverage for Source Generation Signed-off-by: Kenny Pflug --- .../NativeAotMovieRating/packages.lock.json | 10 +- .../DiagnosticDescriptors.cs | 2 +- ...Validation.OpenApi.SourceGeneration.csproj | 1 + .../ValidatorOpenApiEmitter.cs | 2 +- .../ValidatorOpenApiModels.cs | 18 +- ...PortableValidationOpenApiGeneratorTests.cs | 291 +++++++++++++++++- .../TestModelFactory.cs | 52 ++++ .../ValidatorOpenApiEmitterTests.cs | 282 +++++++++++++++++ .../ValidatorOpenApiModelEqualityTests.cs | 220 +++++++++++++ .../ValidationOpenApiAttributeTests.cs | 202 ++++++++++++ .../ValidationOpenApiExtensionGuardTests.cs | 27 ++ 11 files changed, 1090 insertions(+), 17 deletions(-) create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/TestModelFactory.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiExtensionGuardTests.cs diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index cba75b7..28b5403 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -189,21 +189,21 @@ "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } }, - "net10.0/osx-arm64": { + "net10.0/linux-x64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", "requested": "[10.0.7, )", "resolved": "10.0.7", "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==", "dependencies": { - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.7" + "runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.7" } }, - "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { + "runtime.linux-x64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", "resolved": "10.0.7", - "contentHash": "ycFCaZwEvd0nNqcW53l0KWM+fz74owXpWj5C/z0GjznwAtHwmGTeh3vGTGFrXD9LEagX8G3cHRtzGDrTabIrwQ==" + "contentHash": "bz+Di9NJXvaWTvoma5Pf9JrgFj6MGkbPo9dlWRo+jOHXDEme511jeWVEBWoPdoDe6BjDWRngGi9P9EUBCCgzgw==" } } } -} +} \ No newline at end of file diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs index fc77c14..6a3f9b5 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs @@ -2,7 +2,7 @@ namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; -internal static class DiagnosticDescriptors +public static class DiagnosticDescriptors { private const string Category = "Light.PortableResults.Validation.OpenApi.SourceGeneration"; diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj index 74ebaf2..ee7f7ec 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj @@ -7,6 +7,7 @@ disable false false + false Source generator for Light.PortableResults.Validation OpenAPI metadata. $(NoWarn);RS1036;RS2008 diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 386eefc..0a56dd8 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -6,7 +6,7 @@ namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; -internal static class ValidatorOpenApiEmitter +public static class ValidatorOpenApiEmitter { public static string Emit(ValidatorModel model) { diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs index 7f06a95..10d9f6b 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs @@ -97,7 +97,7 @@ ImmutableArray right } } -internal sealed class ValidatorModel +public sealed class ValidatorModel { public ValidatorModel( string metadataName, @@ -133,7 +133,7 @@ ImmutableArray examples public ImmutableArray Examples { get; } } -internal sealed class RuleCallModel +public sealed class RuleCallModel { public RuleCallModel( string code, @@ -163,7 +163,7 @@ ImmutableArray metadataSchemaProperties public ImmutableArray MetadataSchemaProperties { get; } } -internal sealed class MetadataValueModel +public sealed class MetadataValueModel { public MetadataValueModel(string key, object? value, bool hasConstantValue, string typeName) { @@ -179,7 +179,7 @@ public MetadataValueModel(string key, object? value, bool hasConstantValue, stri public string TypeName { get; } } -internal sealed class MetadataSchemaPropertyModel +public sealed class MetadataSchemaPropertyModel { public MetadataSchemaPropertyModel(string key, string typeName) { @@ -191,7 +191,7 @@ public MetadataSchemaPropertyModel(string key, string typeName) public string TypeName { get; } } -internal sealed class ErrorHintModel +public sealed class ErrorHintModel { public ErrorHintModel( string code, @@ -209,7 +209,7 @@ ImmutableArray metadataSchemaProperties public ImmutableArray MetadataSchemaProperties { get; } } -internal sealed class ExampleHintModel +public sealed class ExampleHintModel { public ExampleHintModel( string code, @@ -230,14 +230,14 @@ ImmutableArray metadataValues public ImmutableArray MetadataValues { get; } } -internal enum RuleMetadataShape +public enum RuleMetadataShape { Registered = 0, TypedComparison = 1, TypedRange = 2 } -internal sealed class RuleSchemaKeyComparer : IEqualityComparer +public sealed class RuleSchemaKeyComparer : IEqualityComparer { public static RuleSchemaKeyComparer Instance { get; } = new (); @@ -271,7 +271,7 @@ public int GetHashCode(RuleCallModel obj) } } -internal sealed class InlineSchemaRuleComparer : IEqualityComparer +public sealed class InlineSchemaRuleComparer : IEqualityComparer { public static InlineSchemaRuleComparer Instance { get; } = new (); diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs index 2b7aa4d..e0da885 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/PortableValidationOpenApiGeneratorTests.cs @@ -570,6 +570,295 @@ string value .Contain(["LPRSG0010", "LPRSG0011"]); } + [Fact] + public void Generator_ShouldReportDiagnosticForGenericValidator() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class GenericValidator : Validator + { + public GenericValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0002"); + result.GeneratedSources.Should().BeEmpty(); + } + + [Fact] + public void Generator_ShouldReportDiagnosticForNestedValidator() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + public partial class Outer + { + [GeneratePortableValidationOpenApi] + public sealed partial class NestedValidator : Validator + { + public NestedValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0002"); + result.GeneratedSources.Should().BeEmpty(); + } + + [Fact] + public void Generator_ShouldReportDiagnosticsForCustomBaseClassWithoutLocalPerformValidation() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + public abstract class CustomBaseValidator : Validator + { + protected CustomBaseValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + + [GeneratePortableValidationOpenApi] + public sealed partial class DerivedValidator : CustomBaseValidator + { + public DerivedValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id) + .Should() + .Contain(["LPRSG0002", "LPRSG0004"]); + result.GeneratedSources.Should().BeEmpty(); + } + + [Fact] + public void Generator_ShouldReportDiagnosticForAsyncValidator() + { + var result = RunGenerator( + """ + using System.Threading; + using System.Threading.Tasks; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class AsyncOnlyValidator : AsyncValidator + { + public AsyncOnlyValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValueTask> PerformValidationAsync( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value, + CancellationToken cancellationToken + ) => new (checkpoint.ToValidatedValue(value)); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0003"); + result.GeneratedSources.Should().BeEmpty(); + } + + [Fact] + public void Generator_ShouldReportInfoWhenNoDocumentableRulesAreFound() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class EmptyValidator : Validator + { + public EmptyValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0008"); + } + + [Fact] + public void Generator_ShouldEmitAllowUnknownErrorCodesAndSuppressNoRulesDiagnostic() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi(AllowUnknownErrorCodes = true)] + public sealed partial class AllowUnknownValidator : Validator + { + public AllowUnknownValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().NotContain("LPRSG0008"); + result.GeneratedSources.Single().Should().Contain("builder.AllowUnknownErrorCodes();"); + } + + [Fact] + public void Generator_ShouldReportDiagnosticWhenRuleMetadataBindsToMissingParameter() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.Definitions; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class MissingParameterValidator : Validator + { + public MissingParameterValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + int value + ) + { + context.Check(value).HasMissingMetadataParameter(3); + return checkpoint.ToValidatedValue(value); + } + } + + public static class MissingParameterChecks + { + [ValidationRule("BadMetadata")] + [ValidationRuleMetadata("key", "doesNotExist")] + public static Check HasMissingMetadataParameter(this Check check, int divisor) => check; + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0007"); + } + + [Fact] + public void Generator_ShouldReportDiagnosticWhenErrorContractCodeDoesNotMatchRule() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.Definitions; + using Light.PortableResults.Validation.Messaging; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class ContractMismatchValidator : Validator + { + public ContractMismatchValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + int value + ) + { + context.Check(value).HasMismatchedContract(3); + return checkpoint.ToValidatedValue(value); + } + } + + public static class ContractMismatchChecks + { + [ValidationRule("RuleCode", ErrorDefinitionType = typeof(MismatchDefinition))] + public static Check HasMismatchedContract(this Check check, int divisor) => check; + } + + [ValidationErrorContract("DifferentCode")] + public sealed class MismatchDefinition : ValidationErrorDefinition + { + public override ValidationErrorMessage ProvideMessage( + in ValidationErrorMessageContext context + ) => new("mismatch"); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0009"); + } + + [Fact] + public void Generator_ShouldWarnWhenExampleMetadataKeyIsNotDeclaredBySchema() + { + var result = RunGenerator( + """ + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + [PortableValidationOpenApiErrorHint("Code")] + [PortableValidationOpenApiErrorMetadataProperty("Code", "lowerBoundary", typeof(int))] + [PortableValidationOpenApiExampleHint("Code", Target = "field")] + [PortableValidationOpenApiExampleMetadata("Code", "unknownKey", 1)] + public sealed partial class ExampleSchemaMismatchValidator : Validator + { + public ExampleSchemaMismatchValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + string value + ) => checkpoint.ToValidatedValue(value); + } + """ + ); + + result.Diagnostics.Select(static diagnostic => diagnostic.Id).Should().Contain("LPRSG0012"); + } + private static GeneratorRunResult RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); @@ -611,7 +900,7 @@ private static MetadataReference[] CreateMetadataReferences() AddAssembly(references, typeof(PortableOpenApiSchemaTypeMapper).Assembly); AddAssembly(references, typeof(OpenApiSchema).Assembly); AddAssembly(references, typeof(PortableValidationOpenApiGenerator).Assembly); - return references.Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + return references.Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); } private static void AddAssembly(ISet references, Assembly assembly) diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/TestModelFactory.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/TestModelFactory.cs new file mode 100644 index 0000000..6bd602c --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/TestModelFactory.cs @@ -0,0 +1,52 @@ +using System.Collections.Immutable; +using System.Linq; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +internal static class TestModelFactory +{ + public static RuleCallModel TypedRule( + string code, + string? typedValueTypeName, + RuleMetadataShape shape = RuleMetadataShape.TypedComparison + ) => + new ( + code, + shape, + target: null, + message: null, + typedValueTypeName, + ImmutableArray.Empty, + ImmutableArray.Empty + ); + + public static RuleCallModel InlineSchemaRule( + string code, + params (string Key, string TypeName)[] properties + ) => + new ( + code, + RuleMetadataShape.Registered, + target: null, + message: null, + typedValueTypeName: null, + ImmutableArray.Empty, + [..properties.Select(static property => new MetadataSchemaPropertyModel(property.Key, property.TypeName))] + ); + + public static RuleCallModel RegisteredRule( + string code, + string? target = null, + string? message = null, + ImmutableArray metadataValues = default + ) => + new ( + code, + RuleMetadataShape.Registered, + target, + message, + typedValueTypeName: null, + metadataValues.IsDefault ? ImmutableArray.Empty : metadataValues, + ImmutableArray.Empty + ); +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs new file mode 100644 index 0000000..66d851c --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Immutable; +using FluentAssertions; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class ValidatorOpenApiEmitterTests +{ + [Fact] + public void Emit_ShouldRenderNamespaceAccessibilityAndAllowUnknownErrorCodes() + { + var model = new ValidatorModel( + "TestApp.SampleValidator", + "SampleValidator", + "TestApp", + "public", + "SampleValidator", + allowUnknownErrorCodes: true, + rules: [TestModelFactory.RegisteredRule("NotEmpty", "name", "name must not be empty")], + hints: ImmutableArray.Empty, + examples: ImmutableArray.Empty + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("namespace TestApp"); + source.Should().Contain("public partial class SampleValidator : IPortableValidationOpenApiContract"); + source.Should().Contain("builder.WithErrorCodes(\"NotEmpty\");"); + source.Should().Contain("builder.WithErrorExample(\"NotEmpty\", \"name\", \"name must not be empty\");"); + source.Should().Contain("builder.AllowUnknownErrorCodes();"); + } + + [Fact] + public void Emit_ShouldOmitNamespaceAndAllowUnknownErrorCodesWhenNotRequested() + { + var model = new ValidatorModel( + "SampleValidator", + "SampleValidator", + namespaceName: null, + "internal", + "SampleValidator", + allowUnknownErrorCodes: false, + rules: [TestModelFactory.RegisteredRule("NotEmpty")], + hints: ImmutableArray.Empty, + examples: ImmutableArray.Empty + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().NotContain("namespace "); + source.Should().Contain("internal partial class SampleValidator"); + source.Should().NotContain("builder.AllowUnknownErrorCodes();"); + } + + [Fact] + public void Emit_ShouldRenderTypedHelpersAndSkipUnknownTypedCodes() + { + var model = ModelWithRules( + TestModelFactory.TypedRule("InRange", "global::System.Int32", RuleMetadataShape.TypedRange), + TestModelFactory.TypedRule("EqualTo", "global::System.Int32"), + TestModelFactory.TypedRule("NotEqualTo", "global::System.Int32"), + TestModelFactory.TypedRule("GreaterThan", "global::System.Int32"), + TestModelFactory.TypedRule("GreaterThanOrEqualTo", "global::System.Int32"), + TestModelFactory.TypedRule("LessThan", "global::System.Int32"), + TestModelFactory.TypedRule("LessThanOrEqualTo", "global::System.Int32"), + TestModelFactory.TypedRule("NotInRange", "global::System.Int32"), + TestModelFactory.TypedRule("ExclusiveRange", "global::System.Int32"), + // Unknown typed code -> GetTypedHelperName returns null -> emitter skips it. + TestModelFactory.TypedRule("UnknownTyped", "global::System.Int32"), + // Typed shape without a value type name -> also skipped. + TestModelFactory.TypedRule("InRange", typedValueTypeName: null) + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("builder.WithInRangeError();"); + source.Should().Contain("builder.WithEqualToError();"); + source.Should().Contain("builder.WithNotEqualToError();"); + source.Should().Contain("builder.WithGreaterThanError();"); + source.Should().Contain("builder.WithGreaterThanOrEqualToError();"); + source.Should().Contain("builder.WithLessThanError();"); + source.Should().Contain("builder.WithLessThanOrEqualToError();"); + source.Should().Contain("builder.WithNotInRangeError();"); + source.Should().Contain("builder.WithExclusiveRangeError();"); + // The unknown typed code and the typed rule without a value type produce no typed-helper call, + // so only the nine known typed helpers are emitted. + source.Split(["Error();"], StringSplitOptions.None).Should().HaveCount(10); + source.Should().NotContain("builder.WithUnknownTyped"); + } + + [Fact] + public void Emit_ShouldDeduplicateTypedRulesSharingTheSameSchemaKey() + { + var model = ModelWithRules( + TestModelFactory.TypedRule("InRange", "global::System.Int32", RuleMetadataShape.TypedRange), + TestModelFactory.TypedRule("InRange", "global::System.Int32", RuleMetadataShape.TypedRange) + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("builder.WithInRangeError();"); + source.Split(["builder.WithInRangeError"], StringSplitOptions.None).Should().HaveCount(2); + } + + [Fact] + public void Emit_ShouldRenderInlineSchemaForRegisteredRulesAndDeduplicate() + { + var model = ModelWithRules( + TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")), + TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")) + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("builder.WithErrorMetadata(\"DivisibleBy\", _ => new OpenApiSchema"); + source.Should().Contain("[\"divisor\"] = PortableOpenApiSchemaTypeMapper.Map()"); + source.Should().Contain("Required = new HashSet(StringComparer.Ordinal) { \"divisor\" }"); + source.Split(["WithErrorMetadata(\"DivisibleBy\""], StringSplitOptions.None).Should().HaveCount(2); + } + + [Fact] + public void Emit_ShouldRenderMetadataTypeHintsExampleOnlyCodesAndInlineSchemaHints() + { + var model = new ValidatorModel( + "TestApp.HintValidator", + "HintValidator", + "TestApp", + "public", + "HintValidator", + allowUnknownErrorCodes: false, + rules: ImmutableArray.Empty, + hints: + [ + new ErrorHintModel( + "CustomCode", + "global::TestApp.CustomMetadata", + ImmutableArray.Empty + ), + // Duplicate metadata-type hint -> deduplicated to a single WithErrorMetadata. + new ErrorHintModel( + "CustomCode", + "global::TestApp.CustomMetadata", + ImmutableArray.Empty + ), + new ErrorHintModel( + "InlineCode", + metadataTypeName: null, + [new MetadataSchemaPropertyModel("limit", "global::System.Int32")] + ), + // Code-only hint -> contributes a registered error code. + new ErrorHintModel( + "PlainCode", + metadataTypeName: null, + ImmutableArray.Empty + ) + ], + examples: [new ExampleHintModel("ExampleOnlyCode", "field", null, ImmutableArray.Empty)] + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("builder.WithErrorMetadata(\"CustomCode\");"); + source.Split(["WithErrorMetadata"], StringSplitOptions.None) + .Should() + .HaveCount(2); + source.Should().Contain("builder.WithErrorMetadata(\"InlineCode\", _ => new OpenApiSchema"); + source.Should().Contain("builder.WithErrorCodes(\"ExampleOnlyCode\", \"PlainCode\");"); + source.Should().Contain("builder.WithErrorExample(\"ExampleOnlyCode\", \"field\", null);"); + } + + [Fact] + public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() + { + var metadataValues = ImmutableArray.Create( + MetadataValue("text", "hello"), + MetadataValue("ch", 'q'), + MetadataValue("flagTrue", true), + MetadataValue("flagFalse", false), + MetadataValue("b", (byte) 1), + MetadataValue("sb", (sbyte) -2), + MetadataValue("sh", (short) -3), + MetadataValue("ush", (ushort) 4), + MetadataValue("i", 5), + MetadataValue("ui", 6U), + MetadataValue("l", 7L), + MetadataValue("ul", 8UL), + MetadataValue("f", 1.5F), + MetadataValue("d", 2.5D), + MetadataValue("m", 3.5M), + MetadataValue("nothing", null), + MetadataValue("fallback", Guid.Empty), + MetadataValue("escapes", "a\\b\"c\nd\te\rfg\0h\ai\bj\fk\vl'm") + ); + var model = ModelWithRules( + TestModelFactory.RegisteredRule("Types", "field", "message", metadataValues) + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("[\"text\"] = \"hello\""); + source.Should().Contain("[\"ch\"] = 'q'"); + source.Should().Contain("[\"flagTrue\"] = true"); + source.Should().Contain("[\"flagFalse\"] = false"); + source.Should().Contain("[\"b\"] = 1"); + source.Should().Contain("[\"sb\"] = -2"); + source.Should().Contain("[\"sh\"] = -3"); + source.Should().Contain("[\"ush\"] = 4"); + source.Should().Contain("[\"i\"] = 5"); + source.Should().Contain("[\"ui\"] = 6U"); + source.Should().Contain("[\"l\"] = 7L"); + source.Should().Contain("[\"ul\"] = 8UL"); + source.Should().Contain("[\"f\"] = 1.5F"); + source.Should().Contain("[\"d\"] = 2.5D"); + source.Should().Contain("[\"m\"] = 3.5M"); + source.Should().Contain("[\"nothing\"] = null"); + source.Should().Contain("[\"fallback\"] = \"00000000-0000-0000-0000-000000000000\""); + source.Should() + .Contain("[\"escapes\"] = \"a\\\\b\\\"c\\nd\\te\\rf\\u0001g\\0h\\ai\\bj\\fk\\vl\\'m\""); + } + + [Fact] + public void Emit_ShouldOmitMetadataDictionaryWhenAValueIsNotConstant() + { + var metadataValues = ImmutableArray.Create( + new MetadataValueModel("min", value: null, hasConstantValue: false, "global::System.Int32") + ); + var model = ModelWithRules( + TestModelFactory.RegisteredRule("MinLength", "name", "name is too short", metadataValues) + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain("builder.WithErrorExample(\"MinLength\", \"name\", \"name is too short\");"); + source.Should().NotContain("new Dictionary"); + } + + [Fact] + public void Emit_ShouldDeduplicateExampleHintsAndRenderMetadata() + { + var metadataValues = + ImmutableArray.Create(MetadataValue("lowerBoundary", 1), MetadataValue("upperBoundary", 5)); + var model = new ValidatorModel( + "TestApp.ExampleValidator", + "ExampleValidator", + "TestApp", + "public", + "ExampleValidator", + allowUnknownErrorCodes: false, + rules: ImmutableArray.Empty, + hints: ImmutableArray.Empty, + examples: + [ + new ExampleHintModel("RatingTooLow", "rating", null, metadataValues), + new ExampleHintModel("RatingTooLow", "rating", null, metadataValues) + ] + ); + + var source = ValidatorOpenApiEmitter.Emit(model); + + source.Should().Contain( + "builder.WithErrorExample(\"RatingTooLow\", \"rating\", null, new Dictionary(StringComparer.Ordinal) { [\"lowerBoundary\"] = 1, [\"upperBoundary\"] = 5 });" + ); + source.Split(["WithErrorExample(\"RatingTooLow\""], StringSplitOptions.None).Should().HaveCount(2); + } + + private static MetadataValueModel MetadataValue(string key, object? value) => + new (key, value, hasConstantValue: true, "global::System.Object"); + + private static ValidatorModel ModelWithRules(params RuleCallModel[] rules) => + new ( + "TestApp.SampleValidator", + "SampleValidator", + "TestApp", + "public", + "SampleValidator", + allowUnknownErrorCodes: false, + [.. rules], + ImmutableArray.Empty, + ImmutableArray.Empty + ); +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs new file mode 100644 index 0000000..39d2e70 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs @@ -0,0 +1,220 @@ +using System.Collections.Immutable; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class ValidatorOpenApiAnalysisTests +{ + // Reuse a production descriptor so the test does not declare a new analyzer rule (which would trip RS2008). + private static readonly DiagnosticDescriptor Descriptor = DiagnosticDescriptors.NoDocumentedRules; + + [Fact] + public void Equals_ShouldReturnTrueForIdenticalAnalyses() + { + var first = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", CreateDiagnostics("a")); + var second = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", CreateDiagnostics("a")); + + first.Equals(second).Should().BeTrue(); + first.Equals((object) second).Should().BeTrue(); + first.GetHashCode().Should().Be(second.GetHashCode()); + } + + [Fact] + public void Equals_ShouldReturnFalseForNull() + { + var analysis = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", ImmutableArray.Empty); + + analysis.Equals(null).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseForDifferentType() + { + var analysis = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", ImmutableArray.Empty); + + // ReSharper disable once SuspiciousTypeConversion.Global -- required for test scenario + // ReSharper disable once RedundantCast + analysis.Equals((object) "not an analysis").Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenHintNameDiffers() + { + var first = new ValidatorOpenApiAnalysis("First.g.cs", "source", ImmutableArray.Empty); + var second = new ValidatorOpenApiAnalysis("Second.g.cs", "source", ImmutableArray.Empty); + + first.Equals(second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenSourceDiffers() + { + var first = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", ImmutableArray.Empty); + var second = new ValidatorOpenApiAnalysis("Hint.g.cs", null, ImmutableArray.Empty); + + first.Equals(second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenDiagnosticCountDiffers() + { + var first = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", CreateDiagnostics("a")); + var second = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", ImmutableArray.Empty); + + first.Equals(second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenDiagnosticContentDiffers() + { + var first = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", CreateDiagnostics("a")); + var second = new ValidatorOpenApiAnalysis("Hint.g.cs", "source", CreateDiagnostics("b")); + + first.Equals(second).Should().BeFalse(); + } + + private static ImmutableArray CreateDiagnostics(string argument) => + [Diagnostic.Create(Descriptor, Location.None, argument)]; +} + +public sealed class RuleSchemaKeyComparerTests +{ + [Fact] + public void Equals_ShouldReturnTrueForSameReference() + { + var rule = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + + RuleSchemaKeyComparer.Instance.Equals(rule, rule).Should().BeTrue(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenOneOperandIsNull() + { + var rule = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + + RuleSchemaKeyComparer.Instance.Equals(rule, null).Should().BeFalse(); + RuleSchemaKeyComparer.Instance.Equals(null, rule).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnTrueForMatchingKeyParts() + { + var first = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + var second = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + + RuleSchemaKeyComparer.Instance.Equals(first, second).Should().BeTrue(); + RuleSchemaKeyComparer.Instance.GetHashCode(first) + .Should() + .Be(RuleSchemaKeyComparer.Instance.GetHashCode(second)); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenCodeDiffers() + { + var first = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + var second = TestModelFactory.TypedRule("NotInRange", "global::System.Int32"); + + RuleSchemaKeyComparer.Instance.Equals(first, second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenShapeDiffers() + { + var first = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + var second = TestModelFactory.TypedRule("InRange", "global::System.Int32", RuleMetadataShape.TypedRange); + + RuleSchemaKeyComparer.Instance.Equals(first, second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenTypedValueTypeNameDiffers() + { + var first = TestModelFactory.TypedRule("InRange", "global::System.Int32"); + var second = TestModelFactory.TypedRule("InRange", typedValueTypeName: null); + + RuleSchemaKeyComparer.Instance.Equals(first, second).Should().BeFalse(); + RuleSchemaKeyComparer.Instance.GetHashCode(second).Should().NotBe(0); + } +} + +public sealed class InlineSchemaRuleComparerTests +{ + [Fact] + public void Equals_ShouldReturnTrueForSameReference() + { + var rule = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")); + + InlineSchemaRuleComparer.Instance.Equals(rule, rule).Should().BeTrue(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenOneOperandIsNull() + { + var rule = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")); + + InlineSchemaRuleComparer.Instance.Equals(rule, null).Should().BeFalse(); + InlineSchemaRuleComparer.Instance.Equals(null, rule).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenCodeDiffers() + { + var first = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")); + var second = TestModelFactory.InlineSchemaRule("MultipleOf", ("divisor", "global::System.Int32")); + + InlineSchemaRuleComparer.Instance.Equals(first, second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenPropertyCountDiffers() + { + var first = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")); + var second = TestModelFactory.InlineSchemaRule( + "DivisibleBy", + ("divisor", "global::System.Int32"), + ("scale", "global::System.Int32") + ); + + InlineSchemaRuleComparer.Instance.Equals(first, second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenPropertyKeyDiffers() + { + var first = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")); + var second = TestModelFactory.InlineSchemaRule("DivisibleBy", ("factor", "global::System.Int32")); + + InlineSchemaRuleComparer.Instance.Equals(first, second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenPropertyTypeNameDiffers() + { + var first = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int32")); + var second = TestModelFactory.InlineSchemaRule("DivisibleBy", ("divisor", "global::System.Int64")); + + InlineSchemaRuleComparer.Instance.Equals(first, second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnTrueForMatchingProperties() + { + var first = TestModelFactory.InlineSchemaRule( + "DivisibleBy", + ("divisor", "global::System.Int32"), + ("scale", "global::System.Int32") + ); + var second = TestModelFactory.InlineSchemaRule( + "DivisibleBy", + ("divisor", "global::System.Int32"), + ("scale", "global::System.Int32") + ); + + InlineSchemaRuleComparer.Instance.Equals(first, second).Should().BeTrue(); + InlineSchemaRuleComparer.Instance.GetHashCode(first) + .Should() + .Be(InlineSchemaRuleComparer.Instance.GetHashCode(second)); + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs new file mode 100644 index 0000000..d4f0088 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs @@ -0,0 +1,202 @@ +using System; +using FluentAssertions; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +// These marker attributes are never instantiated at runtime - they are consumed by the source generator at +// compile time. The unit tests below exercise their guard clauses and property assignments so that the +// hand-written guard logic stays covered and correct. +public sealed class ValidationOpenApiAttributeTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GeneratePortableValidationOpenApi_ShouldExposeAllowUnknownErrorCodes(string? _) + { + var attribute = new GeneratePortableValidationOpenApiAttribute { AllowUnknownErrorCodes = true }; + + attribute.AllowUnknownErrorCodes.Should().BeTrue(); + } + + [Fact] + public void ErrorHint_ShouldCaptureCodeOnly() + { + var attribute = new PortableValidationOpenApiErrorHintAttribute("NotEmpty"); + + (attribute.Code, attribute.MetadataType).Should().Be(("NotEmpty", null)); + } + + [Fact] + public void ErrorHint_ShouldCaptureCodeAndMetadataType() + { + var attribute = new PortableValidationOpenApiErrorHintAttribute("NotEmpty", typeof(MetadataSample)); + + (attribute.Code, attribute.MetadataType).Should().Be(("NotEmpty", typeof(MetadataSample))); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ErrorHint_ShouldRejectMissingCode(string? code) + { + var act = () => new PortableValidationOpenApiErrorHintAttribute(code!); + + act.Should().Throw(); + } + + [Fact] + public void ErrorHint_ShouldRejectNullMetadataType() + { + var act = () => new PortableValidationOpenApiErrorHintAttribute("NotEmpty", null!); + + act.Should().Throw(); + } + + [Fact] + public void ExampleHint_ShouldCaptureCodeTargetAndMessage() + { + var attribute = new PortableValidationOpenApiExampleHintAttribute("NotEmpty") + { + Target = "name", + Message = "name must not be empty" + }; + + (attribute.Code, attribute.Target, attribute.Message) + .Should() + .Be(("NotEmpty", "name", "name must not be empty")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExampleHint_ShouldRejectMissingCode(string? code) + { + var act = () => new PortableValidationOpenApiExampleHintAttribute(code!); + + act.Should().Throw(); + } + + [Fact] + public void ErrorMetadataProperty_ShouldCaptureCodeKeyAndType() + { + var attribute = new PortableValidationOpenApiErrorMetadataPropertyAttribute( + "InRange", + "lowerBoundary", + typeof(int) + ); + + (attribute.Code, attribute.Key, attribute.Type) + .Should() + .Be(("InRange", "lowerBoundary", typeof(int))); + } + + [Theory] + [InlineData(null, "key")] + [InlineData("", "key")] + [InlineData(" ", "key")] + [InlineData("code", null)] + [InlineData("code", "")] + [InlineData("code", " ")] + public void ErrorMetadataProperty_ShouldRejectMissingCodeOrKey(string? code, string? key) + { + var act = () => new PortableValidationOpenApiErrorMetadataPropertyAttribute(code!, key!, typeof(int)); + + act.Should().Throw(); + } + + [Fact] + public void ErrorMetadataProperty_ShouldRejectNullType() + { + var act = () => new PortableValidationOpenApiErrorMetadataPropertyAttribute("InRange", "lowerBoundary", null!); + + act.Should().Throw(); + } + + [Fact] + public void ExampleMetadata_ShouldCaptureStringValue() + { + var attribute = new PortableValidationOpenApiExampleMetadataAttribute("Pattern", "pattern", "[a-z]+"); + + (attribute.Code, attribute.Key, attribute.StringValue) + .Should() + .Be(("Pattern", "pattern", "[a-z]+")); + } + + [Fact] + public void ExampleMetadata_ShouldAllowNullStringValue() + { + var attribute = new PortableValidationOpenApiExampleMetadataAttribute("Pattern", "pattern", (string?) null); + + attribute.StringValue.Should().BeNull(); + } + + [Fact] + public void ExampleMetadata_ShouldCaptureIntValueAsInt64() + { + var attribute = new PortableValidationOpenApiExampleMetadataAttribute("InRange", "lowerBoundary", 1); + + attribute.Int64Value.Should().Be(1L); + } + + [Fact] + public void ExampleMetadata_ShouldCaptureLongValue() + { + var attribute = + new PortableValidationOpenApiExampleMetadataAttribute("InRange", "upperBoundary", 9_000_000_000L); + + attribute.Int64Value.Should().Be(9_000_000_000L); + } + + [Fact] + public void ExampleMetadata_ShouldCaptureBooleanValue() + { + var attribute = new PortableValidationOpenApiExampleMetadataAttribute("EnumName", "ignoreCase", true); + + attribute.BooleanValue.Should().BeTrue(); + } + + [Fact] + public void ExampleMetadata_ShouldCaptureDoubleValue() + { + var attribute = new PortableValidationOpenApiExampleMetadataAttribute("InRange", "lowerBoundary", 1.5); + + attribute.DoubleValue.Should().Be(1.5); + } + + [Fact] + public void ExampleMetadata_ShouldCaptureTypeValue() + { + var attribute = new PortableValidationOpenApiExampleMetadataAttribute("Enum", "enumType", typeof(DayOfWeek)); + + attribute.TypeValue.Should().Be(typeof(DayOfWeek)); + } + + [Fact] + public void ExampleMetadata_ShouldRejectNullTypeValue() + { + var act = () => new PortableValidationOpenApiExampleMetadataAttribute("Enum", "enumType", (Type) null!); + + act.Should().Throw(); + } + + [Theory] + [InlineData(null, "key")] + [InlineData("", "key")] + [InlineData(" ", "key")] + [InlineData("code", null)] + [InlineData("code", "")] + [InlineData("code", " ")] + public void ExampleMetadata_ShouldRejectMissingCodeOrKey(string? code, string? key) + { + var act = () => new PortableValidationOpenApiExampleMetadataAttribute(code!, key!, 1); + + act.Should().Throw(); + } + + // ReSharper disable once ClassNeverInstantiated.Local -- only used as a metadata type argument + private sealed class MetadataSample; +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiExtensionGuardTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiExtensionGuardTests.cs new file mode 100644 index 0000000..49a2a52 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiExtensionGuardTests.cs @@ -0,0 +1,27 @@ +using System; +using FluentAssertions; +using Light.PortableResults.AspNetCore.OpenApi.ErrorContracts; +using Microsoft.AspNetCore.Builder; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +public sealed class ValidationOpenApiExtensionGuardTests +{ + [Fact] + public void RegisterBuiltInValidationErrors_ShouldRejectNullBuilder() + { + var act = static () => ((ErrorMetadataContractsBuilder) null!).RegisterBuiltInValidationErrors(); + + act.Should().Throw(); + } + + [Fact] + public void ProducesPortableValidationProblemFor_ShouldRejectNullBuilder() + { + var act = static () => + ((RouteHandlerBuilder) null!).ProducesPortableValidationProblemFor(); + + act.Should().Throw(); + } +} From fffe7f822d3ca2d5ac81f314ae87755f58e74331 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 22 May 2026 07:43:41 +0200 Subject: [PATCH 16/17] fix: PortableOpenApiErrorExampleEntry.GetHashCode now ignores metadata order Signed-off-by: Kenny Pflug --- .../PortableOpenApiErrorExampleEntry.cs | 12 +++++++++-- .../PortableOpenApiResponseBuilderTests.cs | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs index c512676..149d1b1 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs @@ -72,11 +72,19 @@ public override int GetHashCode() hashCode.Add(Message, StringComparer.Ordinal); if (Metadata is not null) { + // Equals treats Metadata as an unordered set of key/value pairs, so the hash code must + // be independent of the dictionary's enumeration order. Combine each pair's hash with a + // commutative XOR aggregate before folding it into the HashCode. + var metadataHash = 0; foreach (var (key, value) in Metadata) { - hashCode.Add(key, StringComparer.Ordinal); - hashCode.Add(value); + metadataHash ^= HashCode.Combine( + StringComparer.Ordinal.GetHashCode(key), + value?.GetHashCode() ?? 0 + ); } + + hashCode.Add(metadataHash); } return hashCode.ToHashCode(); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs index 190bb63..33d753a 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiResponseBuilderTests.cs @@ -93,6 +93,27 @@ public void ErrorExampleEntries_ShouldIncludeMessageInEqualityAndHashing() first.Should().NotBe(differentMessage); } + [Fact] + public void ErrorExampleEntries_ShouldHashMetadataOrderIndependently() + { + var metadata = new Dictionary(StringComparer.Ordinal) + { + ["minLength"] = 3, + ["maxLength"] = 10, + }; + var reorderedMetadata = new Dictionary(StringComparer.Ordinal) + { + ["maxLength"] = 10, + ["minLength"] = 3, + }; + + var first = new PortableOpenApiErrorExampleEntry("Length", "name", "invalid length", metadata); + var reordered = new PortableOpenApiErrorExampleEntry("Length", "name", "invalid length", reorderedMetadata); + + first.Should().Be(reordered); + first.GetHashCode().Should().Be(reordered.GetHashCode()); + } + [Fact] public void ProducesPortableValidationProblem_ShouldAccumulateMessageAwareExamples() { From 04336a4c330a2386d6ee2c9509aa1cb63bddd2a4 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Fri, 22 May 2026 07:54:00 +0200 Subject: [PATCH 17/17] fix: PortableResultsOpenApiDocumentTransformer now assigns examples correctly by error category Signed-off-by: Kenny Pflug --- ...rtableResultsOpenApiDocumentTransformer.cs | 72 +++++++++++++----- ...eResultsOpenApiDocumentTransformerTests.cs | 76 +++++++++++++++++++ 2 files changed, 130 insertions(+), 18 deletions(-) diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 3bdb03e..9c7a801 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -206,22 +206,39 @@ private void ApplyResponseExample( int statusCode ) { - var errorAttribute = attributes - .OfType() - .FirstOrDefault(static attribute => attribute.ErrorExamples is { Length: > 0 }); - if (errorAttribute is null) + // Every error marker that contributes to this slot may carry its own examples. The slot already + // forbids two markers of the same kind (see ApplyResponseMetadataAsync), so each contributing kind + // gets a distinct, stably named example instead of letting attribute ordering pick a single winner. + Dictionary? examples = null; + foreach (var attribute in attributes) { - return; - } + if (attribute is not PortableOpenApiErrorResponseAttributeBase { ErrorExamples.Length: > 0 } errorAttribute) + { + continue; + } - mediaType.Examples = new Dictionary(StringComparer.Ordinal) - { - ["ValidationProblem"] = new OpenApiExample + examples ??= new Dictionary(StringComparer.Ordinal); + var (exampleKey, summary) = ResolveExampleIdentity(errorAttribute); + examples[exampleKey] = new OpenApiExample { - Summary = "Validation problem", + Summary = summary, Value = CreateResponseExample(errorAttribute, statusCode) - } - }; + }; + } + + if (examples is not null) + { + mediaType.Examples = examples; + } + } + + private static (string Key, string Summary) ResolveExampleIdentity( + PortableOpenApiErrorResponseAttributeBase attribute + ) + { + return attribute.Kind == PortableOpenApiResponseKind.ValidationProblem ? + ("ValidationProblem", "Validation problem") : + ("Problem", "Problem"); } private JsonObject CreateResponseExample( @@ -229,12 +246,13 @@ private JsonObject CreateResponseExample( int statusCode ) { + var category = ResolveExampleCategory(attribute, statusCode); var example = new JsonObject { - ["type"] = ErrorCategory.Validation.GetTypeUri(), - ["title"] = ErrorCategory.Validation.GetTitle(), + ["type"] = category.GetTypeUri(), + ["title"] = category.GetTitle(), ["status"] = statusCode, - ["detail"] = ErrorCategory.Validation.GetDetail() + ["detail"] = category.GetDetail() }; if (attribute is ProducesPortableValidationProblemAttribute validationAttribute && @@ -244,12 +262,29 @@ int statusCode } else { - AddRichErrorExample(example, attribute.ErrorExamples!); + AddRichErrorExample(example, attribute.ErrorExamples!, category); } return example; } + private static ErrorCategory ResolveExampleCategory( + PortableOpenApiErrorResponseAttributeBase attribute, + int statusCode + ) + { + // Validation problems are always documented with Bad Request semantics, while generic problems + // derive their category from the documented status code so the example envelope is not mislabeled. + if (attribute.Kind == PortableOpenApiResponseKind.ValidationProblem) + { + return ErrorCategory.Validation; + } + + return Enum.IsDefined(typeof(ErrorCategory), statusCode) ? + (ErrorCategory) statusCode : + ErrorCategory.InternalError; + } + private ValidationProblemSerializationFormat ResolveValidationProblemFormat( ProducesPortableValidationProblemAttribute attribute ) @@ -261,7 +296,8 @@ ProducesPortableValidationProblemAttribute attribute private static void AddRichErrorExample( JsonObject example, - IReadOnlyList entries + IReadOnlyList entries, + ErrorCategory category ) { var errors = new JsonArray(); @@ -272,7 +308,7 @@ IReadOnlyList entries ["message"] = entry.Message ?? DefaultValidationExampleMessage, ["code"] = entry.Code, ["target"] = entry.Target, - ["category"] = ErrorCategory.Validation.ToString() + ["category"] = category.ToString() }; AddMetadata(error, entry.Metadata); errors.Add((JsonNode) error); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index b25d245..e7f5004 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -535,6 +535,82 @@ await GetOpenApiDocumentAsync(app), .Equal("name must not be empty", "Validation failed."); } + [Fact] + public async Task Transformer_ShouldEmitDistinctExamplesPerErrorKind_WhenMarkersShareResponseSlot() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/union-examples", static () => TypedResults.Problem()) + .WithName("UnionExamples") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: builder => builder.WithErrorExample("Movie/Gone", target: null, "the movie is gone") + ) + .ProducesPortableValidationProblem( + configure: builder => builder + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorExample("NotEmpty", "name", "name must not be empty") + ); + } + ); + + var response = GetResponse( + await GetOpenApiDocumentAsync(app), + "/minimal/problems/union-examples", + HttpMethod.Get, + StatusCodes.Status400BadRequest + ); + var mediaType = response.Content!["application/problem+json"]; + + // Both contributing markers must surface their own example instead of one being dropped by ordering. + mediaType.Examples.Should().ContainKeys("Problem", "ValidationProblem"); + + var problemExample = ((OpenApiExample) mediaType.Examples!["Problem"]).Value + .Should().BeOfType().Subject; + problemExample["title"]!.ToString().Should().Be("Bad Request"); + var problemErrors = problemExample["errors"].Should().BeOfType().Subject; + problemErrors[0]!["message"]!.ToString().Should().Be("the movie is gone"); + + var validationExample = ((OpenApiExample) mediaType.Examples["ValidationProblem"]).Value + .Should().BeOfType().Subject; + var validationErrors = validationExample["errors"].Should().BeOfType().Subject; + validationErrors[0]!["message"]!.ToString().Should().Be("name must not be empty"); + } + + [Fact] + public async Task Transformer_ShouldDeriveProblemExampleCategoryFromStatusCode() + { + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/conflict-example", static () => TypedResults.Problem()) + .WithName("ConflictExample") + .ProducesPortableProblem( + StatusCodes.Status409Conflict, + configure: builder => builder.WithErrorExample("VersionMismatch", target: null, "version mismatch") + ); + } + ); + + var response = GetResponse( + await GetOpenApiDocumentAsync(app), + "/minimal/problems/conflict-example", + HttpMethod.Get, + StatusCodes.Status409Conflict + ); + var example = ((OpenApiExample) response.Content!["application/problem+json"].Examples!["Problem"]).Value + .Should().BeOfType().Subject; + + // A generic problem must not be mislabeled with validation semantics. + example["title"]!.ToString().Should().Be("Conflict"); + example["status"]!.GetValue().Should().Be(StatusCodes.Status409Conflict); + var errors = example["errors"].Should().BeOfType().Subject; + errors[0]!["category"]!.ToString().Should().Be(nameof(ErrorCategory.Conflict)); + } + [Fact] public async Task Transformer_ShouldMaterializeReferencedResponsesBeforeWritingContent() {