diff --git a/Directory.Packages.props b/Directory.Packages.props
index 91e322f7..b927f12e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,6 +12,8 @@
+
+
diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx
index 0a9bf746..2af9fb1a 100644
--- a/Light.PortableResults.slnx
+++ b/Light.PortableResults.slnx
@@ -56,6 +56,10 @@
+
+
+
+
@@ -81,6 +85,7 @@
+
@@ -92,6 +97,7 @@
+
diff --git a/README.md b/README.md
index 4b5111b2..6d0254b7 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,80 @@ 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, 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:
+
+```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]`. 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",
+ Message = "rating must be at least 1"
+)]
+[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(...)`:
diff --git a/ai-plans/0043-0-openapi-source-generation.md b/ai-plans/0043-0-openapi-source-generation.md
new file mode 100644
index 00000000..495916a1
--- /dev/null
+++ b/ai-plans/0043-0-openapi-source-generation.md
@@ -0,0 +1,417 @@
+# Validator-Driven OpenAPI Source Generation
+
+## Rationale
+
+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.
+
+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 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 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
+
+- [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
+
+### Project Structure
+
+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.
+
+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:
+
+- 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")]
+[ValidationRuleMetadata(
+ ValidationErrorMetadataKeys.UpperBoundary,
+ 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.
+
+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:
+
+```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 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
+
+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 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 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:
+
+- 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 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.
+
+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.
+
+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.
+
+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`
+
+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:
+
+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 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 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 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 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.
+
+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.
+
+### 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-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 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`
+
+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.
+- `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:
+
+- 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.
+
+### 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 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.
+
+### 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
+
+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 unsupported marked validators: non-partial, nested, generic, indirect inheritance, and async-validator shapes
+- diagnostics for unsupported `Must` / `Custom` patterns
+- 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
+- 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.
+
+### Documentation and Sample
+
+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, now using IsInRange(1, 5)
+}
+
+app.MapPut("/api/moviesRatings", AddMovieRating)
+ .ProducesPortableValidationProblemFor(
+ configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich));
+```
+
+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; 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 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.**
+
+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.
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 00000000..baa4e0bb
--- /dev/null
+++ b/ai-plans/0043-1-improve-documentation-hints.md
@@ -0,0 +1,87 @@
+# Improve Validation OpenAPI Documentation Hints
+
+## Rationale
+
+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
+
+- [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
+
+### Scope
+
+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(...)`.
+
+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
+
+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.
+
+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:
+
+- **`[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.
+
+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("RatingTooLow")]
+
+[PortableValidationOpenApiExampleHint("RatingTooLow", Target = "rating")]
+[PortableValidationOpenApiExampleMetadata("RatingTooLow", "lowerBoundary", 1)]
+[PortableValidationOpenApiExampleMetadata("RatingTooLow", "upperBoundary", 5)]
+
+[PortableValidationOpenApiErrorHint("MovieAlreadyRated", typeof(MovieAlreadyRatedMetadata))]
+[PortableValidationOpenApiExampleHint("MovieAlreadyRated", Target = "movieId")]
+```
+
+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.
+
+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.
+
+### Generated Output
+
+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.
+
+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 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.
+
+### Hint Scope and Placement
+
+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.
+
+### Composition with `AllowUnknownErrorCodes`
+
+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
+
+The diagnostic philosophy mirrors 0043-0:
+
+- **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.
+
+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.
+
+Diagnostic IDs continue in the `LPRSG` range and point at the most specific attribute syntax available — the conflicting attribute, not the validator declaration.
+
+### Tests
+
+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.
+
+### Documentation and Examples
+
+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.
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 00000000..84ed9059
--- /dev/null
+++ b/ai-plans/0043-2-openapi-example-messages.md
@@ -0,0 +1,214 @@
+# 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
+
+- [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
+
+### 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`. 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:
+
+```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 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. 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. 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`:
+
+```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."`.
+
+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.
+
+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.
+
+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
+
+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;
+- 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.
+
+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.
diff --git a/ai-plans/0043-3-plan-deviations.md b/ai-plans/0043-3-plan-deviations.md
new file mode 100644
index 00000000..c6777db3
--- /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.
diff --git a/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs b/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs
index 366adda0..d789f483 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 e7472f3d..e1607560 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 58a026ef..051ce31c 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 b6b8c050..e081e883 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 ee17cb5c..3bf1184d 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 9bd94831..87008ce2 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/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json
index cba75b7c..28b54034 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.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs
index 05ba7cad..9c7a8019 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;
@@ -24,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;
@@ -182,19 +185,227 @@ 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
+ )
+ {
+ // 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)
+ {
+ if (attribute is not PortableOpenApiErrorResponseAttributeBase { ErrorExamples.Length: > 0 } errorAttribute)
+ {
+ continue;
+ }
+
+ examples ??= new Dictionary(StringComparer.Ordinal);
+ var (exampleKey, summary) = ResolveExampleIdentity(errorAttribute);
+ examples[exampleKey] = new OpenApiExample
+ {
+ 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(
+ PortableOpenApiErrorResponseAttributeBase attribute,
+ int statusCode
+ )
+ {
+ var category = ResolveExampleCategory(attribute, statusCode);
+ var example = new JsonObject
+ {
+ ["type"] = category.GetTypeUri(),
+ ["title"] = category.GetTitle(),
+ ["status"] = statusCode,
+ ["detail"] = category.GetDetail()
+ };
+
+ if (attribute is ProducesPortableValidationProblemAttribute validationAttribute &&
+ ResolveValidationProblemFormat(validationAttribute) == ValidationProblemSerializationFormat.AspNetCoreCompatible)
+ {
+ AddAspNetCoreCompatibleValidationExample(example, validationAttribute.ErrorExamples!);
+ }
+ else
+ {
+ 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
+ )
+ {
+ return attribute.HasFormatOverride ?
+ attribute.Format :
+ _writeOptions.ValidationProblemSerializationFormat;
+ }
+
+ private static void AddRichErrorExample(
+ JsonObject example,
+ IReadOnlyList entries,
+ ErrorCategory category
+ )
+ {
+ var errors = new JsonArray();
+ foreach (var entry in entries)
+ {
+ var error = new JsonObject
+ {
+ ["message"] = entry.Message ?? DefaultValidationExampleMessage,
+ ["code"] = entry.Code,
+ ["target"] = entry.Target,
+ ["category"] = category.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(entry.Message ?? DefaultValidationExampleMessage));
+
+ 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 c0a65e1a..280caacd 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 00000000..149d1b1b
--- /dev/null
+++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiErrorExampleEntry.cs
@@ -0,0 +1,118 @@
+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,
+ string? message,
+ IReadOnlyDictionary? metadata
+ )
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(code);
+
+ Code = code;
+ Target = target;
+ Message = message;
+ 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 example validation message.
+ ///
+ public string? Message { 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) &&
+ string.Equals(Message, other.Message, 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);
+ 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)
+ {
+ metadataHash ^= HashCode.Combine(
+ StringComparer.Ordinal.GetHashCode(key),
+ value?.GetHashCode() ?? 0
+ );
+ }
+
+ hashCode.Add(metadataHash);
+ }
+
+ 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 5c0ec1a9..72b8b1a3 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 577e9397..0d0b9b9d 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,23 @@ 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,
+ string? message = null,
+ IReadOnlyDictionary? metadata = null
+ )
+ {
+ _attribute.ErrorExamples = PortableOpenApiBuilderUtilities.AppendExamples(
+ _attribute.ErrorExamples,
+ new PortableOpenApiErrorExampleEntry(code, target, message, 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 6dd60f89..2873d2f0 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,23 @@ 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,
+ string? message = null,
+ IReadOnlyDictionary? metadata = null
+ )
+ {
+ _attribute.ErrorExamples = PortableOpenApiBuilderUtilities.AppendExamples(
+ _attribute.ErrorExamples,
+ new PortableOpenApiErrorExampleEntry(code, target, message, 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 a8cd8388..ebc063d5 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/CodeWriter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CodeWriter.cs
new file mode 100644
index 00000000..5cc73e11
--- /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/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs
new file mode 100644
index 00000000..6a3f9b55
--- /dev/null
+++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs
@@ -0,0 +1,151 @@
+using Microsoft.CodeAnalysis;
+
+namespace Light.PortableResults.Validation.OpenApi.SourceGeneration;
+
+public 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
+ );
+
+ 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
+ );
+
+ 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
new file mode 100644
index 00000000..5f57662c
--- /dev/null
+++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/KnownTypeNames.cs
@@ -0,0 +1,32 @@
+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 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 =
+ "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 =
+ "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 00000000..ee7f7ec4
--- /dev/null
+++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/Light.PortableResults.Validation.OpenApi.SourceGeneration.csproj
@@ -0,0 +1,22 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+ disable
+ false
+ 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 00000000..5524be51
--- /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 00000000..8bac4235
--- /dev/null
+++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs
@@ -0,0 +1,1562 @@
+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;
+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, 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 && examples.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,
+ examples
+ );
+ 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 is KnownTypeNames.AsyncValidator or 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);
+ var displayName = TryGetExplicitDisplayName(semanticModel, invocations[0], cancellationToken) ?? target;
+ 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,
+ displayName,
+ diagnostics,
+ cancellationToken
+ );
+ if (rule is not null)
+ {
+ rules.Add(rule);
+ }
+ }
+ }
+
+ private static RuleCallModel? CreateRuleCall(
+ SemanticModel semanticModel,
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol symbol,
+ AttributeData ruleAttribute,
+ string? target,
+ string? displayName,
+ 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);
+ 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,
+ 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 { Declaration.Variables.Count: 1 } localDeclaration:
+ 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? 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())
+ {
+ 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 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)
+ {
+ 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,
+ ICollection diagnostics
+ )
+ {
+ foreach (var hint in GetErrorHints(validatorType.GetAttributes(), diagnostics))
+ {
+ yield return hint;
+ }
+
+ foreach (var hint in GetErrorHints(performValidation.GetAttributes(), diagnostics))
+ {
+ yield return hint;
+ }
+ }
+
+ private static IEnumerable GetErrorHints(
+ IEnumerable attributes,
+ ICollection diagnostics
+ )
+ {
+ 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 ||
+ attribute.ConstructorArguments[0].Value is not string code)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ diagnostics.Add(
+ Diagnostic.Create(
+ DiagnosticDescriptors.InvalidHint,
+ GetAttributeLocation(attribute),
+ "error hint code must not be empty"
+ )
+ );
+ continue;
+ }
+
+ parentCodes.Add(code);
+ var metadataTypeName = attribute.ConstructorArguments.Length > 1 &&
+ attribute.ConstructorArguments[1].Value is ITypeSymbol metadataType ?
+ metadataType.ToDisplayString(FullyQualifiedTypeFormat) :
+ null;
+ 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 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, message, 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);
+ if (attribute is null)
+ {
+ return false;
+ }
+
+ foreach (var namedArgument in attribute.NamedArguments)
+ {
+ if (namedArgument is { Key: "AllowUnknownErrorCodes", Value.Value: 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) =>
+ accessibility switch
+ {
+ Accessibility.Public => "public",
+ Accessibility.Internal => "internal",
+ _ => "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 00000000..0a56dd80
--- /dev/null
+++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+namespace Light.PortableResults.Validation.OpenApi.SourceGeneration;
+
+public static class ValidatorOpenApiEmitter
+{
+ public static string Emit(ValidatorModel model)
+ {
+ 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)
+ {
+ writer
+ .Write("namespace ")
+ .WriteLine(model.NamespaceName)
+ .OpenBrace();
+ }
+
+ 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(writer, model);
+ EmitExamples(writer, model);
+
+ if (model.AllowUnknownErrorCodes)
+ {
+ writer.WriteLine("builder.AllowUnknownErrorCodes();");
+ }
+
+ writer.CloseBrace().CloseBrace();
+
+ if (model.NamespaceName is not null)
+ {
+ writer.CloseBrace();
+ }
+
+ return writer.ToString();
+ }
+
+ 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(
+ 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 &&
+ 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();
+ if (registeredCodes.Length > 0)
+ {
+ 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)
+ .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<")
+ .Write(hint.MetadataTypeName!)
+ .Write(">(")
+ .Write(ToStringLiteral(hint.Code))
+ .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 &&
+ rule.MetadataSchemaProperties.Length > 0
+ )
+ .Distinct(InlineSchemaRuleComparer.Instance)
+ .OrderBy(static rule => rule.Code, StringComparer.Ordinal))
+ {
+ EmitInlineSchemaConfiguration(writer, rule);
+ }
+
+ 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;
+ }
+
+ writer
+ .Write("builder.")
+ .Write(helperName)
+ .Write('<')
+ .Write(rule.TypedValueTypeName)
+ .WriteLine(">();");
+ }
+
+ 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 &&
+ rule.MetadataSchemaProperties.Length > 0
+ ))
+ {
+ writer.WriteLine();
+ }
+
+ return writer;
+ }
+
+ private static CodeWriter EmitInlineSchemaConfiguration(CodeWriter writer, RuleCallModel rule)
+ {
+ 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(code))
+ .WriteLine(", _ => new OpenApiSchema")
+ .WriteLine("{")
+ .IncreaseIndent()
+ .WriteLine("Type = JsonSchemaType.Object,")
+ .WriteLine("Properties = new Dictionary(StringComparer.Ordinal)")
+ .WriteLine("{")
+ .IncreaseIndent();
+
+ foreach (var property in properties)
+ {
+ writer
+ .Write("[")
+ .Write(ToStringLiteral(property.Key))
+ .Write("] = PortableOpenApiSchemaTypeMapper.Map<")
+ .Write(property.TypeName)
+ .WriteLine(">(),");
+ }
+
+ return writer
+ .DecreaseIndent()
+ .WriteLine("},")
+ .Write("Required = new HashSet(StringComparer.Ordinal) { ")
+ .Write(requiredKeys)
+ .WriteLine(" }")
+ .DecreaseIndent()
+ .Write("}, ")
+ .Write(ToStringLiteral(schemaId))
+ .WriteLine(");");
+ }
+
+ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model)
+ {
+ foreach (var rule in model.Rules)
+ {
+ 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(", ")
+ .Write(rule.Message is null ? "null" : ToStringLiteral(rule.Message));
+
+ if (canEmitMetadata && rule.MetadataValues.Length > 0)
+ {
+ EmitMetadataDictionary(writer.Write(", "), rule.MetadataValues);
+ }
+
+ writer.WriteLine(");");
+ }
+
+ foreach (var example in model.Examples
+ .GroupBy(
+ static example => new
+ {
+ example.Code,
+ Target = example.Target ?? string.Empty,
+ Message = example.Message ?? 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))
+ .Write(", ")
+ .Write(example.Message is null ? "null" : ToStringLiteral(example.Message));
+
+ if (example.MetadataValues.Length > 0)
+ {
+ EmitMetadataDictionary(writer.Write(", "), example.MetadataValues);
+ }
+
+ writer.WriteLine(");");
+ }
+
+ if (model.Rules.Length > 0 || model.Examples.Length > 0)
+ {
+ writer.WriteLine();
+ }
+
+ return writer;
+ }
+
+ private static CodeWriter EmitMetadataDictionary(
+ CodeWriter writer,
+ IEnumerable metadataValues
+ )
+ {
+ var entries = string.Join(
+ ", ",
+ metadataValues.Select(
+ static metadataValue =>
+ $"[{ToStringLiteral(metadataValue.Key)}] = {ToLiteral(metadataValue.Value)}"
+ )
+ );
+
+ return writer
+ .Write("new Dictionary(StringComparer.Ordinal) { ")
+ .Write(entries)
+ .Write(" }");
+ }
+
+ private static string? GetTypedHelperName(RuleCallModel rule) =>
+ rule.Code switch
+ {
+ "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
+ {
+ 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)
+ {
+ var builder = new StringBuilder(value.Length + 2).Append('"');
+ foreach (var c in value)
+ {
+ builder.Append(EscapeChar(c));
+ }
+
+ return builder.Append('"').ToString();
+ }
+
+ private static string EscapeChar(char value) =>
+ value switch
+ {
+ '\\' => @"\\",
+ '"' => "\\\"",
+ '\'' => "\\'",
+ '\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()
+ };
+}
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 00000000..10d9f6bc
--- /dev/null
+++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs
@@ -0,0 +1,318 @@
+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;
+ }
+}
+
+public sealed class ValidatorModel
+{
+ public ValidatorModel(
+ string metadataName,
+ string displayName,
+ string? namespaceName,
+ string accessibility,
+ string className,
+ bool allowUnknownErrorCodes,
+ ImmutableArray rules,
+ ImmutableArray hints,
+ ImmutableArray examples
+ )
+ {
+ MetadataName = metadataName;
+ DisplayName = displayName;
+ NamespaceName = namespaceName;
+ Accessibility = accessibility;
+ ClassName = className;
+ AllowUnknownErrorCodes = allowUnknownErrorCodes;
+ Rules = rules;
+ Hints = hints;
+ Examples = examples;
+ }
+
+ 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; }
+ public ImmutableArray Examples { get; }
+}
+
+public sealed class RuleCallModel
+{
+ public RuleCallModel(
+ string code,
+ RuleMetadataShape shape,
+ string? target,
+ string? message,
+ string? typedValueTypeName,
+ ImmutableArray metadataValues,
+ ImmutableArray metadataSchemaProperties
+ )
+ {
+ Code = code;
+ Shape = shape;
+ Target = target;
+ Message = message;
+ TypedValueTypeName = typedValueTypeName;
+ MetadataValues = metadataValues;
+ MetadataSchemaProperties = 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; }
+}
+
+public 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; }
+}
+
+public sealed class MetadataSchemaPropertyModel
+{
+ public MetadataSchemaPropertyModel(string key, string typeName)
+ {
+ Key = key;
+ TypeName = typeName;
+ }
+
+ public string Key { get; }
+ public string TypeName { get; }
+}
+
+public sealed class ErrorHintModel
+{
+ 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; }
+}
+
+public 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; }
+}
+
+public enum RuleMetadataShape
+{
+ Registered = 0,
+ TypedComparison = 1,
+ TypedRange = 2
+}
+
+public 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;
+ }
+ }
+}
+
+public 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 00000000..8e295673
--- /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 606047a0..3fbc662f 100644
--- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs
+++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorBuilderExtensions.cs
@@ -12,175 +12,327 @@ 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
+ );
+ }
///