Skip to content

feat: media type & size validation for IFormFile uploads (#216)#218

Merged
avgalex merged 8 commits into
masterfrom
feature/issue-216-media-types
Jun 20, 2026
Merged

feat: media type & size validation for IFormFile uploads (#216)#218
avgalex merged 8 commits into
masterfrom
feature/issue-216-media-types

Conversation

@avgalex

@avgalex avgalex commented Jun 20, 2026

Copy link
Copy Markdown
Member

Closes #216.

Problem

A user uploads a file via IFormFile and the generated OpenAPI does not reflect their FluentValidation rules. Two defects:

  1. Missing feature — media types / multipart encoding were never emitted.
  2. Silently-ignored rules — rules on nested IFormFile members are dropped: RuleFor(x => x.File.Length) / RuleFor(x => x.File.ContentType) are named File.Length / File.ContentType, which never match the flat schema property File; and Must(...) carries no introspectable metadata.

Solution

New introspectable, File-level FluentValidation rules in the shared core (MicroElements.OpenApi.FluentValidation.FileUpload) that enforce at runtime AND drive OpenAPI generation:

using MicroElements.OpenApi.FluentValidation.FileUpload;

RuleFor(x => x.File)
    .NotNull()
    .FileContentType("image/jpeg", "image/png")   // allowed media types
    .MaxFileSize(2 * 1024 * 1024);                // 2 MB

Rules: .FileContentType(params string[]), .MaxFileSize(long), .MinFileSize(long), .FileSizeBetween(long, long).

The allowed content types and size limit are appended to the file part description on all three backends; Swashbuckle and NSwag additionally emit the machine-readable multipart encoding. Content types reach the operation filter/processor through the same filtered rule traversal the schema pipeline uses, so conditional (.When(...)) rules are included/excluded consistently. File size has no standard OpenAPI byte keyword, so it is description-only (enforcement stays server-side).

Backend support

Backend content type + size in description machine-readable encoding.contentType
Swashbuckle ✅ (net8/9 = OAS 3.0; net10 = OAS 3.1)
NSwag ✅ via new FluentValidationOperationProcessor (serialized as encodingType — known NSwag limitation)
Microsoft.AspNetCore.OpenApi ❌ not emitted

The issue scenario (document reflects the FV rules) works on all three backends — verified for the native AspNetCore.OpenApi pipeline by a WebApplicationFactory test against /openapi/v1.json (net9 inlines the description on the file property; net10 puts it on the shared IFormFile component).

Notes / limitations

  • NSwag requires registering the operation processor: settings.OperationProcessors.Add(serviceProvider.GetService<FluentValidationOperationProcessor>()) (wired in the NSwag sample). Adds a NSwag.Generation package reference. NSwag serializes the encoding content type as the non-spec encodingType (verified up to NSwag 14.7.x), which is why the description is the guaranteed-visible carrier.
  • Microsoft.AspNetCore.OpenApi encoding.contentType is out of scope here (its operation transformer does not write the multipart request body); on net10 the IFormFile component is shared, so per-endpoint content-type rules would accumulate on that one component. These can be follow-ups.

Compatibility

Purely additive / opt-in — no existing document output changes. Shipped as pre-release 7.1.8-beta.1 for testing (release). Note: a new public API conventionally warrants a minor bump for the eventual stable.

Tests

  • Swashbuckle IFormFileMediaTypeTests — reproduction of the silently-ignored nested rules + encoding/description/back-compat/urlencoded.
  • New project MicroElements.NSwag.FluentValidation.Tests — operation processor.
  • AspNetCore.OpenApi Issue216SpikeTests — end-to-end document assertion.

Full suite green across all TFMs: Swashbuckle 114/114/83 (net8/9/10), NSwag 3/3 (net8/9), AspNetCore.OpenApi 17/17 (net9/10).

🤖 Generated with Claude Code

avgalex and others added 5 commits June 20, 2026 19:34
…enAPI (#216)

Introduces introspectable, File-level FluentValidation rules that both enforce
at runtime and surface metadata for OpenAPI generation:

- .FileContentType(params string[]) / .MaxFileSize / .MinFileSize / .FileSizeBetween
  on IRuleBuilder<T, IFormFile> (core package, shared by all backends).
- Swashbuckle: emits requestBody multipart/form-data encoding.<part>.contentType
  (machine-readable, comma-joined) and a human-readable description note for both
  content types and size limits. File size is never emitted as maxLength.

Root cause of #216: rules on nested IFormFile members (x.File.Length /
x.File.ContentType) name the rule "File.Length"/"File.ContentType", which never
match the flat schema property "File", so they were silently dropped; and Must()
is opaque so allowed content types could not be reflected. The new File-level API
fixes both. Encoding is read via the same filtered rule traversal the schema
pipeline uses, keeping conditional rules consistent.

Tests reproduce the issue scenario and lock the documented limitation of the old
nested-member rules. Green on net8.0/net9.0 (Microsoft.OpenApi v1) and net10.0
(OPENAPI_V2); full suite 114 passed, no regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Schema rules (NSwagFluentValidationRuleProvider): FileContentType / FileSize
  append allowed content types and size limits to the file part description.
- New FluentValidationOperationProcessor (IOperationProcessor) emits
  multipart/form-data encoding for IFormFile parts restricted via
  .FileContentType(...). Registered as a scoped service; the sample wires it via
  settings.OperationProcessors.Add(...). Adds a NSwag.Generation package reference
  for the IOperationProcessor / OpenApi request-body model.

Known NSwag limitation: OpenApiEncoding.EncodingType serializes as "encodingType"
rather than the OpenAPI-spec "contentType" (verified up to NSwag 14.7.x), so the
allowed content types are ALSO surfaced in the part description for guaranteed
visibility regardless of the encoding key name.

New test project MicroElements.NSwag.FluentValidation.Tests covers the operation
processor (encoding emitted, urlencoded not polluted, no-rule => no encoding).
Green on net8.0/net9.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mp 7.2.0 (#216)

- Microsoft.AspNetCore.OpenApi: FileContentType/FileSize rules append allowed
  content types and size limits to the file property description (encoding remains
  out of scope for this backend — its operation transformer has no mutable
  multipart request-body path).
- version 7.1.7 -> 7.2.0 (additive feature, minor bump).
- CHANGELOG + README: document the new File-level rules, the backend support
  matrix, and the limitations (file size is description-only; NSwag encodingType).

Full solution green: Swashbuckle 114/114/83 (net8/9/10), NSwag 3/3 (net8/9),
AspNetCore.OpenApi 16/16 (net9/10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent (#216)

Spike-turned-test proves (via WebApplicationFactory + /openapi/v1.json) that the
allowed content types and size limit DO appear in the native Microsoft.AspNetCore.OpenApi
document: on net9 inlined on the multipart `file` property, on net10 on the shared
`#/components/schemas/IFormFile` component (the file part is emitted as a $ref).

Corrects the README matrix wording: the issue scenario (document reflects the FV
rules) works on all three backends; only the extra machine-readable
encoding.contentType differs (not emitted by this backend). Documents the net10
shared-IFormFile-component caveat.

AspNetCore.OpenApi suite: 17/17 on net9.0 and net10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ehavior change)

- FileUploadMetadata: drop stale x-fileSizeBytes mention from IFileSizeValidator
  doc (the extension is never emitted; only a description note is).
- FileUploadDescription.Append: remove redundant null-forgiving operator (the
  IsNullOrEmpty guard already proves non-null).
- Swashbuckle/NSwag encoding emission: replace the value-tuple FirstOrDefault().Meta?
  pattern (null-check on a value-type field) with a clearer Where/Select/FirstOrDefault
  that returns a nullable reference directly; drop the unnecessary Properties.Keys
  snapshot (the loop mutates Encoding, not Properties); collapse the NSwag encoding
  upsert create-branch to one line.

Green: Swashbuckle 114 (net8) / 83 (net10), NSwag 3 (net8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avgalex

avgalex commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

@claude review this PR

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

Claude finished @avgalex's task in 2m 30s —— View job


PR Review: feat: media type & size validation for IFormFile uploads (#216)

  • Read CLAUDE.md for repo guidelines (no CLAUDE.md found — using general standards)
  • Review core file upload types and validators
  • Review Swashbuckle integration
  • Review NSwag integration
  • Review AspNetCore.OpenApi integration
  • Review tests
  • Post final review feedback

Summary

This is a well-architected, clean PR. The approach of introducing introspectable IFileContentTypeValidator and IFileSizeValidator interfaces — rather than trying to reverse-engineer nested-member rules — is the right call and elegantly solves the fundamental problem. Test coverage is solid across all three backends. Below are findings ranked by severity.


Findings

Medium — FileSizeValidator default error message is too generic

File: src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs:104

protected override string GetDefaultMessageTemplate(string errorCode)
    => "'{PropertyName}' has an invalid file size.";

The IsValid method does append MinSizeBytes or MaxSizeBytes to the MessageFormatter, but the default template doesn't use them, so the runtime error message gives users no indication of what the actual limit is. Compare with FluentValidation's own MaximumLengthValidator which includes {MaxLength} in its default template.

A clearer approach would be separate validators (one for min, one for max), or a context-aware template:

// When max is violated:
"'{PropertyName}' must not exceed {MaxSizeBytes} bytes."
// When min is violated:
"'{PropertyName}' must be at least {MinSizeBytes} bytes."

Fix this →


Minor — No guard for inverted bounds in FileSizeBetween

File: src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs:92

public static IRuleBuilderOptions<T, IFormFile> FileSizeBetween<T>(
    this IRuleBuilder<T, IFormFile> ruleBuilder,
    long minBytes,
    long maxBytes)
    => ruleBuilder.SetValidator(new FileSizeValidator<T>(minSizeBytes: minBytes, maxSizeBytes: maxBytes));

If a caller accidentally passes inverted values (.FileSizeBetween(2 * 1024 * 1024, 1024)), FileSizeValidator.IsValid will never return false (both branches can't fire simultaneously), silently generating nonsensical documentation: "File size must be between 2097152 and 1024 bytes.". An early ArgumentException would catch this mistake:

if (maxBytes < minBytes)
    throw new ArgumentException($"maxBytes ({maxBytes}) must be >= minBytes ({minBytes}).", nameof(maxBytes));

Fix this →


Minor — Tests don't cover MinFileSize and FileSizeBetween description branches

File: test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs

FileUploadDescription.FormatSizeNote has three branches (min-only, max-only, both), but the existing tests only exercise the max-only case via UploadProductImageRequestValidator. The min-only and both-bounds branches of FormatSizeNote are untested in schema output. Worth adding:

[Fact]
public void MinFileSize_Adds_MinSize_Description()
{
    var validator = new InlineValidator<UploadProductImageRequest>();
    validator.RuleFor(x => x.File).MinFileSize(1024);
    // ... assert fileProperty.Description.Should().Contain("Minimum file size: 1024 bytes")
}

[Fact]
public void FileSizeBetween_Adds_Range_Description()
{
    var validator = new InlineValidator<UploadProductImageRequest>();
    validator.RuleFor(x => x.File).FileSizeBetween(1024, 2 * 1024 * 1024);
    // ... assert fileProperty.Description.Should().Contain("File size must be between 1024 and 2097152 bytes")
}

Fix this →


Minor — No MIME type format validation for FileContentType

File: src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs:47

FileContentType("not-a-mime-type") passes all normalization checks and would document an invalid content type in the OpenAPI spec. Validating the type/subtype format at build time (a simple pattern check) would catch typos early. This is a defense-in-depth concern that could be left as a follow-up if desired.


Info — Encoding contentType join uses no space after comma

Files: FluentValidationOperationFilter.cs:532, FluentValidationOperationProcessor.cs:113

ContentType = string.Join(",", allowed)       // → "image/jpeg,image/png"
// vs. OpenAPI spec examples:               // → "image/jpeg, image/png"

Both Swashbuckle and NSwag emit comma-joined values without a space. The OpenAPI 3.x specification's examples use ", " as the separator. This is not a breaking issue — consumers should accept both forms — but the output doesn't match spec examples. Consistent behavior across both backends is good; consider ", " for readability.


Info — Startup.cs sample scope is never disposed (pre-existing)

File: samples/SampleNSwagWebApi/Startup.cs:21

var scopedProvider = serviceProvider.CreateScope().ServiceProvider;

The IServiceScope returned by CreateScope() is never disposed. This was pre-existing code (the FluentValidationSchemaProcessor line was already there) and the PR correctly follows the same pattern for the new processor — but the underlying leak is worth flagging for a future cleanup.


What's done well

  • Design: Introspectable interfaces (IFileContentTypeValidator, IFileSizeValidator) are the right abstraction. They integrate cleanly into the existing rule traversal pipeline, ensuring When(...) conditions are respected consistently.
  • Idempotency: FileUploadDescription.Append safely handles repeated processing of the same rule without duplicating description notes.
  • Backend parity: FileUploadDescription is shared across all three backends so the human-readable output is identical everywhere.
  • Documented limitations: The NSwag encodingType key (vs. spec contentType) and the AspNetCore.OpenApi no-encoding limitation are clearly noted in code comments and the PR description.
  • Test coverage: The reproduction test for the original buggy nested-member-rules pattern is an excellent addition — it locks the documented limitation as an explicit test case rather than leaving it implicit.
  • Backwards compatibility: Purely additive; existing documents are unchanged.

… spacing

- FileSizeValidator: context-aware default error message that surfaces the
  violated bound and its limit ("must not exceed {Max} bytes" / "must be at least
  {Min} bytes" / "must be between {Min} and {Max} bytes"), instead of the generic
  "has an invalid file size".
- FileSizeBetween: throw ArgumentException when maxBytes < minBytes (fail-fast;
  prevents silently documenting an impossible range).
- encoding.contentType / NSwag encodingType: join allowed types with ", " to match
  the OpenAPI 3.x spec examples.
- Tests: add MinFileSize and FileSizeBetween description coverage (the min-only and
  both-bounds branches of FormatSizeNote).

Deferred (noted on the PR): MIME-format validation of FileContentType (risk of
false rejections on vnd.* / wildcards / parameters) and the pre-existing
undisposed IServiceScope in the NSwag sample.

Green: Swashbuckle 116 (net8) / 85 (net10), NSwag 3, AspNetCore.OpenApi 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avgalex

avgalex commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review! Addressed in c6fab0c:

  • Medium — generic FileSizeValidator message ✅ — GetDefaultMessageTemplate is now context-aware and surfaces the violated bound + its limit: "must not exceed {MaxSizeBytes} bytes." / "must be at least {MinSizeBytes} bytes." / "must be between {MinSizeBytes} and {MaxSizeBytes} bytes.". Kept a single validator (so FileSizeBetween stays simple) and append both configured bounds in IsValid so the template renders.
  • Minor — inverted bounds in FileSizeBetween ✅ — throws ArgumentException when maxBytes < minBytes (fail-fast).
  • Minor — missing MinFileSize / FileSizeBetween description tests ✅ — added MinFileSize_Adds_Minimum_Size_Description and FileSizeBetween_Adds_Range_Description covering the min-only and both-bounds branches of FormatSizeNote.
  • Info — comma without space in encoding.contentType ✅ — switched to ", " in both Swashbuckle and NSwag (and updated assertions/README) to match the OpenAPI 3.x spec examples.

Deferred (happy to do as follow-ups if you'd like):

  • Minor — MIME format validation for FileContentType — left out for now: a strict type/subtype check risks false rejections on legitimate values (application/vnd.api+json, */*, parameters like text/html; charset=utf-8). Can add a lenient check if you want it.
  • Info — undisposed IServiceScope in SampleNSwagWebApi/Startup.cs — pre-existing in the sample (the schema-processor line already did this); left as-is to avoid an out-of-scope sample refactor.

All suites green after the changes: Swashbuckle 116/116/85 (net8/9/10), NSwag 3/3 (net8/9), AspNetCore.OpenApi 17/17 (net9/10).

@avgalex

avgalex commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

@claude review this commit c6fab0c

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

Claude finished @avgalex's task in 1m 44s —— View job


Review of commit c6fab0c

  • Read commit diff
  • Review FileSizeValidator error message fix
  • Review FileSizeBetween argument guard fix
  • Review new test cases
  • Review comma spacing fix
  • Post final findings

All four items addressed in the previous review are correctly implemented. Findings below in descending severity.


Minor — No test for the new FileSizeBetween guard

The ArgumentException path added to FileSizeBetween is untested. Since this is an explicitly-added fail-fast guard (not framework behavior), a test is worth having:

[Fact]
public void FileSizeBetween_Throws_When_Bounds_Inverted()
{
    var validator = new InlineValidator<UploadProductImageRequest>();
    var act = () => validator.RuleFor(x => x.File).FileSizeBetween(2 * 1024 * 1024, 1024);
    act.Should().Throw<ArgumentException>()
        .WithMessage("*maxBytes*must be greater than or equal to minBytes*");
}

Fix this →


Info — FileSizeBetween with equal bounds produces odd description

FileSizeBetween(N, N) is permitted by the guard (maxBytes < minBytes → throws; maxBytes == minBytes → passes) but FileUploadDescription.FormatSizeNote would emit "File size must be between N and N bytes." The equal-bounds case is admittedly unusual and not a practical concern; just noting it if you ever want to tighten the guard to <= or add a dedicated ExactFileSize helper.


What landed correctly

FileSizeValidator error message (FileUploadValidators.cs:103–109) — clean. The refactored IsValid always appends all configured bounds to the formatter before returning false, so the context-aware GetDefaultMessageTemplate can select the right template without needing to re-inspect the failure reason. Verified the three branches (min+max, max-only, min-only) all render correctly given the configured bounds.

FileSizeBetween guard (FileUploadValidatorExtensions.cs:97–98) — correct. Throws ArgumentException with a clear, parameter-named message.

Comma spacing (FluentValidationOperationFilter.cs:532, FluentValidationOperationProcessor.cs:113) — both backends updated to ", ". Test assertion and README example updated consistently.

New tests (IFormFileMediaTypeTests.cs:119–144) — both MinFileSize_Adds_Minimum_Size_Description and FileSizeBetween_Adds_Range_Description correctly exercise the FormatSizeNote branches they claim to cover. The .Contain(...) strings match the FileUploadDescription.FormatSizeNote output exactly.


The commit is clean. Only the untested guard is a small gap worth closing before merge.

Adds FileSizeBetween_Throws_When_Bounds_Inverted, asserting the ArgumentException
fail-fast guard fires when maxBytes < minBytes (PR review follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avgalex

avgalex commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

Thanks! Addressed in 004a431:

  • Minor — untested FileSizeBetween guard ✅ — added FileSizeBetween_Throws_When_Bounds_Inverted, asserting the ArgumentException fires (with a *maxBytes*must be greater than or equal to*minBytes* message match) when bounds are inverted.

  • Info — FileSizeBetween(N, N) equal bounds — intentionally left permitted: equal bounds is a legitimate (if niche) "exactly N bytes" case, so the guard stays maxBytes < minBytes rather than <=. The "between N and N bytes." wording is a degenerate-but-correct range; not adding a separate ExactFileSize helper to avoid expanding the public API beyond the issue's scope.

IFormFileMediaTypeTests now 10/6 (net8/net10), all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@avgalex avgalex merged commit c456b1f into master Jun 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for media types

1 participant