Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Light.PortableResults.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
<File Path="ai-plans\0043-2-openapi-example-messages.md" />
<File Path="ai-plans\0043-3-plan-deviations.md" />
<File Path="ai-plans\0045-open-api-source-generation-mvc-support.md" />
<File Path="ai-plans\0047-openapi-source-generation-mvc-customization.md" />
<File Path="ai-plans\AGENTS.md" />
</Folder>
<Folder Name="/benchmarks/">
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MovieAlreadyRatedMetadata>("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<TValidator, TEndpointContract>` 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<T>` and `Validator<TSource, TValidated>` 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.

Expand Down
33 changes: 33 additions & 0 deletions ai-plans/0047-openapi-source-generation-mvc-customization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# OpenAPI Source Generation MVC Customization

## Rationale

MVC can now consume generated validation OpenAPI contracts through `[ProducesPortableValidationProblemFor<TValidator>]`, 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<TValidator>(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<TValidator>]` 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<TValidator, TEndpointContract>`. 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%.
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,33 @@ public ProducesPortableValidationProblemForAttribute(
TValidator.ConfigurePortableValidationOpenApi(new PortableValidationProblemOpenApiBuilder(this));
}
}

/// <summary>
/// Documents an MVC validation problem response by applying the generated OpenAPI contract of
/// <typeparamref name="TValidator" /> and then endpoint-local OpenAPI customizations from
/// <typeparamref name="TEndpointContract" />.
/// </summary>
/// <typeparam name="TValidator">The validator type that exposes generated validation OpenAPI metadata.</typeparam>
/// <typeparam name="TEndpointContract">The endpoint-local contract that customizes the generated metadata.</typeparam>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class ProducesPortableValidationProblemForAttribute<TValidator, TEndpointContract>
: ProducesPortableValidationProblemAttribute
where TValidator : IPortableValidationOpenApiContract
where TEndpointContract : IPortableValidationOpenApiContract
{
/// <summary>
/// Initializes a new instance of
/// <see cref="ProducesPortableValidationProblemForAttribute{TValidator, TEndpointContract}" />.
/// </summary>
/// <param name="statusCode">The documented HTTP status code.</param>
/// <param name="contentType">The documented content type.</param>
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);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -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<GeneratedRatingValidator>(
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<string> 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<OpenApiSchemaReference>().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<JsonObject>().Subject;
}
}

public sealed class GeneratedRatingDto
Expand Down Expand Up @@ -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<GeneratedValidationMvcEndpointMetadata>()
.WithErrorCodes(ValidationErrorCodes.NotNull)
.WithErrorMetadata<GeneratedValidationMvcEndpointInlineMetadata>("EndpointCustom")
.WithErrorExample(ValidationErrorCodes.NotNull, "comment", "comment must not be null")
.WithErrorExample(
"EndpointCustom",
"comment",
"comment has endpoint-local metadata",
new Dictionary<string, object?> { ["reason"] = "customization" }
)
.AllowUnknownErrorCodes();
}
}

[ApiController]
[Route("generated-validation")]
public sealed class GeneratedValidationOpenApiController : ControllerBase
Expand All @@ -171,4 +344,13 @@ public IActionResult PostRating()
{
return Problem();
}

[HttpPost("mvc-customized")]
[ProducesPortableValidationProblemFor<GeneratedRatingValidator, GeneratedValidationMvcEndpointContract>(
StatusCodes.Status422UnprocessableEntity
)]
public IActionResult PostCustomizedRating()
{
return Problem();
}
}
Loading
Loading