Skip to content

[FromForm] rules not applied for MVC controllers in MicroElements.AspNetCore.OpenApi.FluentValidation (follow-up to #170) #232

Description

@avgalex

Follow-up to #170, which was fixed only in the Swashbuckle pipeline.

Summary

With MicroElements.AspNetCore.OpenApi.FluentValidation (the native Microsoft.AspNetCore.OpenApi integration, net9.0/net10.0), FluentValidation rules are not applied to a [FromForm] DTO bound by an MVC controller action.

Reported by @bux in #170 (comment) (v7.2.1). Reproduced locally.

Repro

public class Issue170FormDto
{
    public string Name { get; set; } = string.Empty;
    public int Age { get; set; }
}

public class Issue170FormDtoValidator : AbstractValidator<Issue170FormDto>
{
    public Issue170FormDtoValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(42);
        RuleFor(x => x.Age).GreaterThanOrEqualTo(7).LessThanOrEqualTo(99);
    }
}

[ApiController]
[Route("api/issue170-form")]
public class Issue170ReproController : ControllerBase
{
    [HttpPost]
    public IActionResult Post([FromForm] Issue170FormDto dto) => Ok(dto.Name);
}

Generated document (net10.0):

"requestBody": {
  "content": {
    "application/x-www-form-urlencoded": {
      "schema": {
        "type": "object",
        "properties": {
          "Name": { "type": "string" },
          "Age":  { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["integer", "string"], "format": "int32" }
        }
      }
    }
  },
  "required": true
}

Expected: Name carries minLength: 1 / maxLength: 42, Age carries minimum: 7 / maximum: 99, and Name is in required.

Minimal APIs are not affectedapp.MapPost("/api/upload", ([FromForm] UploadImageRequest r) => ...) emits a $ref to a component schema, so FluentValidationSchemaTransformer runs normally and the rules land (verified in the same document).

Root cause

  1. For MVC controllers, ApiExplorer flattens a [FromForm] complex parameter into individual form-field descriptions — the same flattening that required special handling for [FromQuery] in Required property in optional nested type in [FromQuery] parameter is wrongly marked as required #209 / Validator for nested type in [FromQuery] parameter is considered even when not used #211.
  2. Microsoft.AspNetCore.OpenApi builds the request-body schema inline from those descriptions instead of referencing the DTO's JsonTypeInfo.
  3. Therefore FluentValidationSchemaTransformer is never invoked for Issue170FormDto — it only sees the leaf string / int schemas.
  4. FluentValidationOperationTransformer.ApplyRulesToRequestBody (src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs, lines ~90-140) handles only multipart/form-data and only writes encoding.<part>.contentType for Issue Add support for media types #216. It never applies ordinary constraints, and application/x-www-form-urlencoded is not inspected at all.

The Swashbuckle pipeline already covers this via RequestBodyRuleApplicator (src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/RequestBodyRuleApplicator.cs), which iterates both form content types and applies the rules to the inline or $ref-ed schema.

Proposed fix

Port the RequestBodyRuleApplicator behavior into FluentValidationOperationTransformer:

  • resolve the DTO type from the operation's ApiDescription parameters whose BindingSource is Form,
  • apply the rule set to the inline request-body schema for both multipart/form-data and application/x-www-form-urlencoded,
  • keep the existing Add support for media types #216 encoding.contentType behavior.

Gotcha: in the flattened controller schema the property keys are the binding names (Name, Age — PascalCase), not the JSON-serialized names. The property lookup must match the binding name and must not assume the INameResolver / JSON naming policy output. A case-insensitive lookup with an INameResolver fallback (the shape used for #230) is probably the right approach.

Affected

  • MicroElements.AspNetCore.OpenApi.FluentValidation 7.2.1 (and earlier), net9.0 and net10.0
  • MVC controller actions only; minimal APIs work
  • Swashbuckle and NSwag pipelines are unaffected

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions