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 @@ -60,6 +60,7 @@
<File Path="ai-plans\0043-1-improve-documentation-hints.md" />
<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\AGENTS.md" />
</Folder>
<Folder Name="/benchmarks/">
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,7 @@ Comparison and range codes are polymorphic at the global code level, so the vali

### 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<TValidator>(...)` on the endpoint:
`Light.PortableResults.Validation.OpenApi` also includes an opt-in source generator for validation responses. Mark a synchronous validator with `[GeneratePortableValidationOpenApi]`, make it `partial`, and apply the generated contract from Minimal API endpoints or MVC actions:

```csharp
using Light.PortableResults.Validation;
Expand All @@ -1459,9 +1459,23 @@ app.MapPut("/api/movieRatings", AddMovieRating)
.ProducesPortableValidationProblemFor<AddMovieRatingValidator>(
configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)
);

[ApiController]
[Route("api/movieRatings")]
public sealed class MovieRatingsController : ControllerBase
{
[HttpPut]
[ProducesPortableValidationProblemFor<AddMovieRatingValidator>(
Format = ValidationProblemSerializationFormat.Rich
)]
public IActionResult Put(MovieRatingDto dto)
{
// ...
}
}
```

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 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 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
39 changes: 39 additions & 0 deletions ai-plans/0045-open-api-source-generation-mvc-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# OpenAPI Source Generation MVC Support

## Rationale

The validation OpenAPI source generator already emits a framework-neutral static contract through `IPortableValidationOpenApiContract`. Minimal APIs can consume that contract via `ProducesPortableValidationProblemFor<TValidator>(...)`, but MVC actions still require manually duplicating the generated validation OpenAPI metadata on `[ProducesPortableValidationProblem]`.

This plan adds an MVC attribute-based adapter that applies the generated validator contract to the existing PortableResults OpenAPI metadata model. The source generator itself should remain unchanged and MVC-unaware.

## Acceptance Criteria

- [x] MVC actions can document source-generated validation OpenAPI metadata with an attribute such as `[ProducesPortableValidationProblemFor<TValidator>]`.
- [x] The new MVC attribute reuses `IPortableValidationOpenApiContract` and `PortableValidationProblemOpenApiBuilder` instead of introducing reflection, runtime validator instantiation, or MVC-specific generated source.
- [x] The new MVC attribute ships from `Light.PortableResults.Validation.OpenApi` together with the existing source-generator APIs, without adding an OpenAPI dependency to `Light.PortableResults.AspNetCore.Mvc`.
- [x] Existing Minimal API source-generation behavior remains unchanged.
- [x] The attribute supports the same response-slot basics as `[ProducesPortableValidationProblem]`: status code, content type, validation problem format override, top-level metadata type, and `AllowUnknownErrorCodes`.
- [x] Generated schema narrowing, inline metadata contracts, typed validation helper contracts, and response examples appear in MVC OpenAPI documents the same way they appear for Minimal APIs.
- [x] Documentation is updated to describe both Minimal API and MVC source-generation usage, including the attribute-specific limitation around additive endpoint-local customization.
- [x] Automated tests are written.

## Technical Details

Add a public MVC-facing attribute to `Light.PortableResults.Validation.OpenApi`, for example `ProducesPortableValidationProblemForAttribute<TValidator>`. It should inherit from `ProducesPortableValidationProblemAttribute` and constrain `TValidator` to `IPortableValidationOpenApiContract`.

The constructor should accept the same status-code and content-type values as `ProducesPortableValidationProblemAttribute`, call the base constructor, then apply the generated validator contract by wrapping the current attribute in a `PortableValidationProblemOpenApiBuilder`:

```csharp
TValidator.ConfigurePortableValidationOpenApi(
new PortableValidationProblemOpenApiBuilder(this));
```

To make that possible without placing the attribute in `Light.PortableResults.AspNetCore.OpenApi`, change the existing `PortableValidationProblemOpenApiBuilder(ProducesPortableValidationProblemAttribute attribute)` constructor from `internal` to `public`. This is acceptable because the builder is already the public configuration API and the attribute is already public metadata.

Do not modify the source generator analysis or emitter for MVC. The generated partial validator should continue to implement only `IPortableValidationOpenApiContract`; framework-specific consumption stays in small adapter APIs.

Named attribute properties inherited from `ProducesPortableValidationProblemAttribute` and its base classes continue to work for overriding final metadata after the constructor runs, especially `Format`, `TopLevelMetadataType`, and `AllowUnknownErrorCodes`. Avoid promising additive named-argument customization for array properties such as `ErrorCodes`, `InlineErrorMetadataCodes`, or `ErrorExamples`, because setting those properties on an attribute replaces the data populated by the generated contract. Endpoint-local additions for MVC should usually be modeled with validator hints or, if necessary, a future explicitly additive attribute API.

Add tests in the validation OpenAPI and ASP.NET Core OpenAPI test areas. Cover direct attribute construction, generated MVC document output, response examples, format overrides, top-level metadata, and non-exhaustive unknown-code behavior. Existing Minimal API tests should continue to pass unchanged. Keep the code coverage over 90% - use coverlet.collector to validate your code changes.

Update the README source-generation section so it no longer describes the feature as Minimal API-only. Show the Minimal API helper and the MVC attribute side by side, and document the MVC customization caveat.
3 changes: 2 additions & 1 deletion ai-plans/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ In this folder, we only keep Markdown files for AI plans. Each file has a four-d
1. Each plan consists of exactly the three following elements: a rationale, acceptance criteria, and technical details.
2. A rationale describes the overarching goal of the plan. Keep this concise, only elaborate when special circumstances require it. Use free text in this section of the plan.
3. Acceptance criteria is a list of requirements that must be met for the plan to be considered complete. Use check marks `- [ ]` for these in Markdown.
4. Technical details describes the changes on a technical level. Which classes and members should be extended, which design patterns should be used? How do we ensure performance? Avoid going into too much detail (do not write the finished implementation), but focus on the high-level design and how things are connected. It should give the implementer a clear picture of what needs to be done, but also give her or him freedom to implement the plan in a way that she or he deems best. The length of this section is highly dependent on the task that the plan addresses.
4. Technical details describes the changes on a technical level. Which classes and members should be extended, which design patterns should be used? How do we ensure performance? Avoid going into too much detail (do not write the finished implementation), but focus on the high-level design and how parts of the codebase are affected. It should give the implementer a clear picture of what needs to be done.
5. Regarding automated tests: it is usually enough to note that 'automated tests need to be written' as one check mark in the acceptance criteria list. If there are special requirements for the tests, these can be elaborated in the technical details section. But normally, you can simply leave these technical details out if the automated tests can be written in a straightforward way.
6. When a plan should involve micro benchmarks, these need to be included in the acceptance criteria list and optionally in the technical details section. As with automated tests, if the BenchmarkDotNet benchmarks can be written in a straightforward way, leave out the technical details.
7. In general, keep the plan concise and to the point. Do not include general knowledge that the implementer should already know.
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ public sealed class PortableValidationProblemOpenApiBuilder
{
private readonly ProducesPortableValidationProblemAttribute _attribute;

internal PortableValidationProblemOpenApiBuilder(ProducesPortableValidationProblemAttribute attribute) =>
_attribute = attribute;
/// <summary>
/// Initializes a new instance of <see cref="PortableValidationProblemOpenApiBuilder" />.
/// </summary>
/// <param name="attribute">The validation problem attribute to configure.</param>
public PortableValidationProblemOpenApiBuilder(ProducesPortableValidationProblemAttribute attribute) =>
_attribute = attribute ?? throw new ArgumentNullException(nameof(attribute));

/// <summary>
/// Narrows the top-level metadata schema to <typeparamref name="TMetadata" />.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- Built-in OpenAPI metadata contracts for Light.PortableResults.Validation error codes.
- Opt-in registration extension for the global PortableResults OpenAPI contract registry.
- Typed comparison and range helper metadata contracts for endpoint-specific narrowing.
- Opt-in source generator for deriving Minimal API validation OpenAPI metadata from synchronous validators.
- Opt-in source generator for deriving Minimal API and MVC validation OpenAPI metadata from synchronous validators.
- Native AOT compatible.
</PackageReleaseNotes>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using Light.PortableResults.AspNetCore.OpenApi;
using Microsoft.AspNetCore.Http;

namespace Light.PortableResults.Validation.OpenApi;

/// <summary>
/// Documents an MVC validation problem response by applying the generated OpenAPI contract of
/// <typeparamref name="TValidator" />.
/// </summary>
/// <typeparam name="TValidator">The validator type that exposes generated validation OpenAPI metadata.</typeparam>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class ProducesPortableValidationProblemForAttribute<TValidator>
: ProducesPortableValidationProblemAttribute
where TValidator : IPortableValidationOpenApiContract
{
/// <summary>
/// Initializes a new instance of
/// <see cref="ProducesPortableValidationProblemForAttribute{TValidator}" />.
/// </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)
{
TValidator.ConfigurePortableValidationOpenApi(new PortableValidationProblemOpenApiBuilder(this));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
using FluentAssertions;
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Http.Writing;
using Light.PortableResults.Validation;
using Light.PortableResults.Validation.Definitions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
using Xunit;

Expand Down Expand Up @@ -69,9 +69,60 @@ public async Task ProducesPortableValidationProblemFor_ShouldApplyGeneratedSchem
.Contain("\"message\":\"comment must be between 10 and 1000 characters long\"");
exampleErrors.ToJsonString().Should().Contain("\"message\":\"rating must be between 1 and 5\"");

var genericProblemResponse = (OpenApiResponse) operation.Responses![StatusCodes.Status500InternalServerError.ToString()];
var genericProblemResponse =
(OpenApiResponse) operation.Responses![StatusCodes.Status500InternalServerError.ToString()];
genericProblemResponse.Content!["application/problem+json"].Examples.Should().BeNullOrEmpty();
}

[Fact]
public async Task MvcAttribute_ShouldApplyGeneratedSchemasExamplesAndOverrides()
{
await using var app = ValidationOpenApiDocumentTestUtilities.CreateMvcApp(
contracts => contracts.RegisterBuiltInValidationErrors(),
controllers => controllers.AddApplicationPart(typeof(GeneratedValidationOpenApiIntegrationTests).Assembly)
);

var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app);
var operation = document.Paths["/generated-validation/mvc"].Operations![HttpMethod.Post];
var response = (OpenApiResponse) operation.Responses![StatusCodes.Status422UnprocessableEntity.ToString()];
var mediaType = response.Content!["application/problem+json"];
var schemaReference = (OpenApiSchemaReference) mediaType.Schema!;
var envelope = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent(
document,
ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(schemaReference)
);

envelope.Properties.Should().ContainKey("metadata");
var metadataReference = envelope.Properties["metadata"].Should().BeOfType<OpenApiSchemaReference>().Subject;
var metadataSchema = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent(
document,
ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(metadataReference)
);
metadataSchema.Properties.Should().ContainKey("traceId");

var errors = (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errors"]).Items!;
errors.OneOf.Should().BeNull();
var errorSchemaIds = errors.AnyOf!.Select(
static schema =>
ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema)
)
.ToArray();
errorSchemaIds.Should().Contain(["PortableError__LengthInRange", "PortableError__NotEmpty", "PortableError"]);
errorSchemaIds.Should().ContainSingle(
schemaId => schemaId.StartsWith("PortableError__", StringComparison.Ordinal) &&
schemaId.EndsWith("__InRange", StringComparison.Ordinal)
);

mediaType.Examples.Should().ContainKey("ValidationProblem");
var example = (OpenApiExample) mediaType.Examples["ValidationProblem"];
var body = example.Value.Should().BeOfType<JsonObject>().Subject;
var exampleErrors = body["errors"].Should().BeOfType<JsonArray>().Subject;
exampleErrors.ToJsonString().Should().Contain("\"lowerBoundary\":1");
exampleErrors.ToJsonString().Should().Contain("\"upperBoundary\":5");
exampleErrors.ToJsonString().Should().Contain("\"minLength\":10");
exampleErrors.ToJsonString().Should().Contain("\"maxLength\":1000");
exampleErrors.ToJsonString().Should().Contain("\"message\":\"id must not be empty\"");
}
}

public sealed class GeneratedRatingDto
Expand Down Expand Up @@ -99,3 +150,25 @@ GeneratedRatingDto dto
return checkpoint.ToValidatedValue(dto);
}
}

public sealed class GeneratedValidationMvcMetadata
{
public string TraceId { get; init; } = string.Empty;
}

[ApiController]
[Route("generated-validation")]
public sealed class GeneratedValidationOpenApiController : ControllerBase
{
[HttpPost("mvc")]
[ProducesPortableValidationProblemFor<GeneratedRatingValidator>(
StatusCodes.Status422UnprocessableEntity,
Format = ValidationProblemSerializationFormat.Rich,
TopLevelMetadataType = typeof(GeneratedValidationMvcMetadata),
AllowUnknownErrorCodes = true
)]
public IActionResult PostRating()
{
return Problem();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using FluentAssertions;
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Http.Writing;
using Xunit;

namespace Light.PortableResults.Validation.OpenApi.Tests;
Expand All @@ -20,6 +22,29 @@ public void GeneratePortableValidationOpenApi_ShouldExposeAllowUnknownErrorCodes
attribute.AllowUnknownErrorCodes.Should().BeTrue();
}

[Fact]
public void ProducesPortableValidationProblemFor_ShouldApplyGeneratedContract()
{
var attribute = new ProducesPortableValidationProblemForAttribute<AttributeContractValidator>(
422,
"application/json"
)
{
Format = ValidationProblemSerializationFormat.AspNetCoreCompatible
};

attribute.StatusCode.Should().Be(422);
attribute.ContentType.Should().Be("application/json");
attribute.TopLevelMetadataType.Should().Be(typeof(MetadataSample));
attribute.ErrorCodes.Should().Equal("NotEmpty");
attribute.InlineErrorMetadataCodes.Should().Equal("Custom");
attribute.InlineErrorMetadataContracts.Should().HaveCount(1);
attribute.ErrorExamples.Should().ContainSingle().Which.Code.Should().Be("NotEmpty");
attribute.AllowUnknownErrorCodes.Should().BeTrue();
attribute.HasFormatOverride.Should().BeTrue();
attribute.Format.Should().Be(ValidationProblemSerializationFormat.AspNetCoreCompatible);
}

[Fact]
public void ErrorHint_ShouldCaptureCodeOnly()
{
Expand Down Expand Up @@ -199,4 +224,17 @@ public void ExampleMetadata_ShouldRejectMissingCodeOrKey(string? code, string? k

// ReSharper disable once ClassNeverInstantiated.Local -- only used as a metadata type argument
private sealed class MetadataSample;

private sealed class AttributeContractValidator : IPortableValidationOpenApiContract
{
public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder)
{
builder
.WithMetadata<MetadataSample>()
.WithErrorCodes("NotEmpty")
.WithErrorMetadata<MetadataSample>("Custom")
.WithErrorExample("NotEmpty", "name", "name must not be empty")
.AllowUnknownErrorCodes();
}
}
}
Loading
Loading