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();
+ }
+ }
}