diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx index 39694d9..4868cb3 100644 --- a/Light.PortableResults.slnx +++ b/Light.PortableResults.slnx @@ -61,6 +61,7 @@ + diff --git a/README.md b/README.md index f50ab1d..29cf5a9 100644 --- a/README.md +++ b/README.md @@ -1473,9 +1473,43 @@ public sealed class MovieRatingsController : ControllerBase // ... } } + +public sealed class MovieRatingsEndpointOpenApiContract : IPortableValidationOpenApiContract +{ + public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder) + { + builder + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithErrorCodes(ValidationErrorCodes.NotNull) + .WithErrorMetadata("MovieAlreadyRated") + .WithErrorExample( + "MovieAlreadyRated", + "movieId", + "movie has already been rated" + ) + .AllowUnknownErrorCodes(); + } +} + +[ApiController] +[Route("api/movieRatings")] +public sealed class MovieRatingsCustomizedController : ControllerBase +{ + [HttpPut] + [ProducesPortableValidationProblemFor< + AddMovieRatingValidator, + MovieRatingsEndpointOpenApiContract + >( + StatusCodes.Status422UnprocessableEntity + )] + public IActionResult Put(MovieRatingDto dto) + { + // ... + } +} ``` -The generated contract calls the same builder APIs you would write by hand. For Minimal APIs, the endpoint's `configure` callback runs afterward, so you can still set the validation format, add manual metadata contracts, or call `AllowUnknownErrorCodes()` for errors that are outside the validator's documentable rules. For MVC, named attribute properties such as `Format`, `TopLevelMetadataType`, and `AllowUnknownErrorCodes` can override the generated response metadata after the attribute constructor runs. Array properties such as `ErrorCodes`, `InlineErrorMetadataCodes`, and `ErrorExamples` replace the generated values when set on the MVC attribute; prefer validator hints for endpoint-local additions until an explicitly additive MVC attribute API exists. +The generated contract calls the same builder APIs you would write by hand. For Minimal APIs, the endpoint's `configure` callback runs afterward, so you can still set the validation format, add manual metadata contracts, or call `AllowUnknownErrorCodes()` for errors that are outside the validator's documentable rules. For MVC, the existing one-generic-parameter attribute remains the simple path when you only need generated metadata plus scalar named-property overrides such as `Format`, `TopLevelMetadataType`, or `AllowUnknownErrorCodes`. When an MVC action needs additive endpoint-local customization, use `ProducesPortableValidationProblemFor` and put the extra builder calls into a small `IPortableValidationOpenApiContract` implementation. The validator contract runs first and the endpoint contract runs second, so generated `ErrorCodes`, inline metadata contracts, typed helper contracts, and examples are preserved while endpoint-local additions are appended. 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. diff --git a/ai-plans/0047-openapi-source-generation-mvc-customization.md b/ai-plans/0047-openapi-source-generation-mvc-customization.md new file mode 100644 index 0000000..a61edd5 --- /dev/null +++ b/ai-plans/0047-openapi-source-generation-mvc-customization.md @@ -0,0 +1,33 @@ +# OpenAPI Source Generation MVC Customization + +## Rationale + +MVC can now consume generated validation OpenAPI contracts through `[ProducesPortableValidationProblemFor]`, but it does not yet have feature parity with Minimal APIs for endpoint-local customization. Minimal APIs can apply the generated validator contract and then append additional builder calls through the `configure` callback. MVC attributes currently rely on named properties after construction, and array properties such as `ErrorCodes`, `InlineErrorMetadataCodes`, and `ErrorExamples` replace generated metadata instead of appending to it. + +This plan adds an attribute-friendly additive customization path for MVC while keeping the source generator framework-neutral. + +## Acceptance Criteria + +- [x] MVC actions can append endpoint-local validation OpenAPI metadata after the generated validator contract is applied. +- [x] MVC supports the same `PortableValidationProblemOpenApiBuilder` customization surface that Minimal APIs support in `ProducesPortableValidationProblemFor(configure: ...)`. +- [x] Generated validator metadata remains preserved when MVC endpoint-local metadata adds error codes, inline metadata contracts, typed helper contracts, response examples, format overrides, top-level metadata, or unknown-code behavior. +- [x] Existing `[ProducesPortableValidationProblemFor]` behavior remains unchanged. +- [x] Existing Minimal API source-generation behavior remains unchanged. +- [x] The source generator remains MVC-unaware and continues to emit only `IPortableValidationOpenApiContract`. +- [x] No OpenAPI dependency is added to `Light.PortableResults.AspNetCore.Mvc`. +- [x] Documentation is updated to describe the MVC customization pattern and remove the limitation around missing additive endpoint-local customization. +- [x] Automated tests are written. + +## Technical Details + +Add a second MVC attribute type in `Light.PortableResults.Validation.OpenApi` which is called `ProducesPortableValidationProblemForAttribute`. It should inherit from `ProducesPortableValidationProblemAttribute` and constrain both generic parameters to `IPortableValidationOpenApiContract`. + +The constructor should accept the same status code and content type values as the existing MVC source-generation attribute. It should create one `PortableValidationProblemOpenApiBuilder` for `this`, call `TValidator.ConfigurePortableValidationOpenApi(builder)` first, and then call `TEndpointContract.ConfigurePortableValidationOpenApi(builder)` second. This mirrors Minimal API ordering where generated metadata is applied before the endpoint-local `configure` callback. + +Endpoint-local MVC customization should be expressed as a small hand-written contract type implementing `IPortableValidationOpenApiContract`. That type provides a static `ConfigurePortableValidationOpenApi` method and can call the same builder APIs as Minimal APIs: `UseFormat`, `WithMetadata`, `WithErrorCodes`, `WithErrorMetadata`, typed validation helper extensions, `WithErrorExample`, and `AllowUnknownErrorCodes`. + +Do not change `ValidatorOpenApiAnalyzer`, `ValidatorOpenApiEmitter`, or generated validator source. The existing one-generic-parameter MVC attribute remains the simple path for endpoints that only need generated validator metadata plus scalar named-property overrides. + +Add unit tests for the new attribute proving that endpoint-local metadata appends to generated arrays instead of replacing them. Add MVC OpenAPI document integration tests comparing the new two-contract MVC attribute with an equivalent Minimal API endpoint using the `configure` callback. Update README source-generation examples to show Minimal API callback customization and MVC contract-based customization side by side. + +Keep the test code coverage at least above 90%. diff --git a/src/Light.PortableResults.Validation.OpenApi/ProducesPortableValidationProblemForAttribute.cs b/src/Light.PortableResults.Validation.OpenApi/ProducesPortableValidationProblemForAttribute.cs index 655dc2b..df6b9a0 100644 --- a/src/Light.PortableResults.Validation.OpenApi/ProducesPortableValidationProblemForAttribute.cs +++ b/src/Light.PortableResults.Validation.OpenApi/ProducesPortableValidationProblemForAttribute.cs @@ -28,3 +28,33 @@ public ProducesPortableValidationProblemForAttribute( TValidator.ConfigurePortableValidationOpenApi(new PortableValidationProblemOpenApiBuilder(this)); } } + +/// +/// Documents an MVC validation problem response by applying the generated OpenAPI contract of +/// and then endpoint-local OpenAPI customizations from +/// . +/// +/// The validator type that exposes generated validation OpenAPI metadata. +/// The endpoint-local contract that customizes the generated metadata. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ProducesPortableValidationProblemForAttribute + : ProducesPortableValidationProblemAttribute + where TValidator : IPortableValidationOpenApiContract + where TEndpointContract : IPortableValidationOpenApiContract +{ + /// + /// Initializes a new instance of + /// . + /// + /// The documented HTTP status code. + /// The documented content type. + public ProducesPortableValidationProblemForAttribute( + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/problem+json" + ) : base(statusCode, contentType) + { + var builder = new PortableValidationProblemOpenApiBuilder(this); + TValidator.ConfigurePortableValidationOpenApi(builder); + TEndpointContract.ConfigurePortableValidationOpenApi(builder); + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs index c254585..46a1914 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.Json.Nodes; @@ -123,6 +124,148 @@ public async Task MvcAttribute_ShouldApplyGeneratedSchemasExamplesAndOverrides() exampleErrors.ToJsonString().Should().Contain("\"maxLength\":1000"); exampleErrors.ToJsonString().Should().Contain("\"message\":\"id must not be empty\""); } + + [Fact] + public async Task MvcTwoContractAttribute_ShouldMatchEquivalentMinimalApiCustomization() + { + await using var minimalApp = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapPost("/generated-validation/minimal-customized", static () => Results.BadRequest()) + .WithName("GeneratedValidationMinimalCustomized") + .ProducesPortableValidationProblemFor( + StatusCodes.Status422UnprocessableEntity, + configure: GeneratedValidationMvcEndpointContract.ConfigurePortableValidationOpenApi + ); + } + ); + await using var mvcApp = ValidationOpenApiDocumentTestUtilities.CreateMvcApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + controllers => controllers.AddApplicationPart(typeof(GeneratedValidationOpenApiIntegrationTests).Assembly) + ); + + var minimalDocument = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(minimalApp); + var mvcDocument = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(mvcApp); + + var minimalEnvelope = GetResponseEnvelope(minimalDocument, "/generated-validation/minimal-customized"); + var mvcEnvelope = GetResponseEnvelope(mvcDocument, "/generated-validation/mvc-customized"); + var minimalErrors = GetDocumentedErrorItems(minimalEnvelope); + var mvcErrors = GetDocumentedErrorItems(mvcEnvelope); + + minimalEnvelope.Properties.Should().ContainKey("errorDetails"); + mvcEnvelope.Properties.Should().ContainKey("errorDetails"); + minimalErrors.AnyOf.Should().NotBeNull(); + minimalErrors.OneOf.Should().BeNull(); + mvcErrors.AnyOf.Should().NotBeNull(); + mvcErrors.OneOf.Should().BeNull(); + + NormalizeSchemaIds(GetDocumentedErrorSchemaIds(minimalDocument, "/generated-validation/minimal-customized")) + .Should() + .BeEquivalentTo( + NormalizeSchemaIds(GetDocumentedErrorSchemaIds(mvcDocument, "/generated-validation/mvc-customized")) + ); + + GetTopLevelMetadataPropertyNames(minimalDocument, "/generated-validation/minimal-customized") + .Should() + .BeEquivalentTo(GetTopLevelMetadataPropertyNames(mvcDocument, "/generated-validation/mvc-customized")); + GetInlineMetadataPropertyNames( + minimalDocument, + "/generated-validation/minimal-customized", + "EndpointCustom" + ) + .Should() + .BeEquivalentTo( + GetInlineMetadataPropertyNames(mvcDocument, "/generated-validation/mvc-customized", "EndpointCustom") + ); + + var minimalExample = GetValidationProblemExample(minimalDocument, "/generated-validation/minimal-customized"); + var mvcExample = GetValidationProblemExample(mvcDocument, "/generated-validation/mvc-customized"); + + minimalExample.ToJsonString().Should().Be(mvcExample.ToJsonString()); + minimalExample.ToJsonString().Should().Contain("\"code\":\"EndpointCustom\""); + minimalExample.ToJsonString().Should().Contain("\"reason\":\"customization\""); + minimalExample.ToJsonString().Should().Contain("\"code\":\"NotNull\""); + } + + private static OpenApiSchema GetResponseEnvelope(OpenApiDocument document, string path) + { + var response = (OpenApiResponse) document.Paths[path] + .Operations![HttpMethod.Post] + .Responses![StatusCodes.Status422UnprocessableEntity.ToString()]; + var schemaReference = (OpenApiSchemaReference) response.Content!["application/problem+json"].Schema!; + return ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(schemaReference) + ); + } + + private static OpenApiSchema GetDocumentedErrorItems(OpenApiSchema envelope) + { + return (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errorDetails"]).Items!; + } + + private static string[] GetDocumentedErrorSchemaIds(OpenApiDocument document, string path) + { + var errors = GetDocumentedErrorItems(GetResponseEnvelope(document, path)); + return errors.AnyOf! + .Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .OrderBy(static schemaId => schemaId, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] NormalizeSchemaIds(IEnumerable schemaIds) + { + return schemaIds.Select(NormalizeSchemaId).OrderBy(static schemaId => schemaId, StringComparer.Ordinal) + .ToArray(); + } + + private static string NormalizeSchemaId(string schemaId) + { + if (schemaId.EndsWith("__InRange", StringComparison.Ordinal)) + { + return "EndpointScoped__InRange"; + } + + if (schemaId.EndsWith("__EndpointCustom", StringComparison.Ordinal)) + { + return "EndpointScoped__EndpointCustom"; + } + + return schemaId; + } + + private static string[] GetTopLevelMetadataPropertyNames(OpenApiDocument document, string path) + { + var envelope = GetResponseEnvelope(document, path); + var metadataReference = envelope.Properties!["metadata"].Should().BeOfType().Subject; + var metadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(metadataReference) + ); + return metadata.Properties!.Keys.OrderBy(static propertyName => propertyName, StringComparer.Ordinal).ToArray(); + } + + private static string[] GetInlineMetadataPropertyNames(OpenApiDocument document, string path, string codeSuffix) + { + var schemaId = GetDocumentedErrorSchemaIds(document, path) + .Single(id => id.EndsWith("__" + codeSuffix, StringComparison.Ordinal)); + var metadata = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent(document, schemaId + "__Metadata"); + return metadata.Properties!.Keys.OrderBy(static propertyName => propertyName, StringComparer.Ordinal).ToArray(); + } + + private static JsonObject GetValidationProblemExample(OpenApiDocument document, string path) + { + var response = (OpenApiResponse) document.Paths[path] + .Operations![HttpMethod.Post] + .Responses![StatusCodes.Status422UnprocessableEntity.ToString()]; + var example = (OpenApiExample) response.Content!["application/problem+json"].Examples!["ValidationProblem"]; + return example.Value.Should().BeOfType().Subject; + } } public sealed class GeneratedRatingDto @@ -156,6 +299,36 @@ public sealed class GeneratedValidationMvcMetadata public string TraceId { get; init; } = string.Empty; } +public sealed class GeneratedValidationMvcEndpointMetadata +{ + public string TraceId { get; init; } = string.Empty; +} + +public sealed class GeneratedValidationMvcEndpointInlineMetadata +{ + public string Reason { get; init; } = string.Empty; +} + +public sealed class GeneratedValidationMvcEndpointContract : IPortableValidationOpenApiContract +{ + public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder) + { + builder + .UseFormat(ValidationProblemSerializationFormat.AspNetCoreCompatible) + .WithMetadata() + .WithErrorCodes(ValidationErrorCodes.NotNull) + .WithErrorMetadata("EndpointCustom") + .WithErrorExample(ValidationErrorCodes.NotNull, "comment", "comment must not be null") + .WithErrorExample( + "EndpointCustom", + "comment", + "comment has endpoint-local metadata", + new Dictionary { ["reason"] = "customization" } + ) + .AllowUnknownErrorCodes(); + } +} + [ApiController] [Route("generated-validation")] public sealed class GeneratedValidationOpenApiController : ControllerBase @@ -171,4 +344,13 @@ public IActionResult PostRating() { return Problem(); } + + [HttpPost("mvc-customized")] + [ProducesPortableValidationProblemFor( + StatusCodes.Status422UnprocessableEntity + )] + public IActionResult PostCustomizedRating() + { + return Problem(); + } } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs index 6558a80..fc51ccb 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/ValidationOpenApiAttributeTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using FluentAssertions; using Light.PortableResults.AspNetCore.OpenApi; using Light.PortableResults.Http.Writing; @@ -45,6 +46,30 @@ public void ProducesPortableValidationProblemFor_ShouldApplyGeneratedContract() attribute.Format.Should().Be(ValidationProblemSerializationFormat.AspNetCoreCompatible); } + [Fact] + public void ProducesPortableValidationProblemForWithEndpointContract_ShouldAppendEndpointContractMetadata() + { + var attribute = + new ProducesPortableValidationProblemForAttribute< + AttributeContractWithFormatValidator, + AttributeEndpointContract + >(422, "application/json"); + + attribute.StatusCode.Should().Be(422); + attribute.ContentType.Should().Be("application/json"); + attribute.TopLevelMetadataType.Should().Be(typeof(EndpointMetadataSample)); + attribute.ErrorCodes.Should().Equal("NotEmpty", "LengthInRange"); + attribute.InlineErrorMetadataCodes.Should().Equal("Custom", "EndpointCustom", "InRange"); + attribute.InlineErrorMetadataContracts.Should().HaveCount(3); + attribute.ErrorExamples! + .Select(static example => example.Code) + .Should() + .Equal("NotEmpty", "InRange", "LengthInRange"); + attribute.AllowUnknownErrorCodes.Should().BeTrue(); + attribute.HasFormatOverride.Should().BeTrue(); + attribute.Format.Should().Be(ValidationProblemSerializationFormat.AspNetCoreCompatible); + } + [Fact] public void ErrorHint_ShouldCaptureCodeOnly() { @@ -225,6 +250,12 @@ public void ExampleMetadata_ShouldRejectMissingCodeOrKey(string? code, string? k // ReSharper disable once ClassNeverInstantiated.Local -- only used as a metadata type argument private sealed class MetadataSample; + // ReSharper disable once ClassNeverInstantiated.Local -- only used as a metadata type argument + private sealed class EndpointMetadataSample; + + // ReSharper disable once ClassNeverInstantiated.Local -- only used as a metadata type argument + private sealed class EndpointInlineMetadataSample; + private sealed class AttributeContractValidator : IPortableValidationOpenApiContract { public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder) @@ -237,4 +268,36 @@ public static void ConfigurePortableValidationOpenApi(PortableValidationProblemO .AllowUnknownErrorCodes(); } } + + private sealed class AttributeContractWithFormatValidator : IPortableValidationOpenApiContract + { + public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder) + { + builder + .UseFormat(ValidationProblemSerializationFormat.Rich) + .WithMetadata() + .WithErrorCodes("NotEmpty") + .WithErrorMetadata("Custom") + .WithErrorExample("NotEmpty", "name", "name must not be empty"); + } + } + + private sealed class AttributeEndpointContract : IPortableValidationOpenApiContract + { + public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder) + { + builder + .UseFormat(ValidationProblemSerializationFormat.AspNetCoreCompatible) + .WithMetadata() + .WithErrorCodes("LengthInRange") + .WithErrorMetadata("EndpointCustom") + .WithInRangeError("rating", 1, 5) + .WithErrorExample( + "LengthInRange", + "comment", + "comment must be between 10 and 1000 characters long" + ) + .AllowUnknownErrorCodes(); + } + } }