From 39146e42b26eb160d841cfc2113713a03d908825 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 19:34:56 +0300 Subject: [PATCH 1/8] feat(core,swashbuckle): IFormFile media type & size validation for OpenAPI (#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 (core package, shared by all backends). - Swashbuckle: emits requestBody multipart/form-data encoding..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) --- .../FileUpload/FileUploadDescription.cs | 65 +++++ .../FileUpload/FileUploadIntrospection.cs | 49 ++++ .../FileUpload/FileUploadMetadata.cs | 38 +++ .../FileUploadValidatorExtensions.cs | 98 +++++++ .../FileUpload/FileUploadValidators.cs | 107 ++++++++ .../DefaultFluentValidationRuleProvider.cs | 23 ++ .../OpenApi/OpenApiSchemaCompatibility.cs | 8 + .../FluentValidationOperationFilter.cs | 47 ++++ .../IFormFileMediaTypeTests.cs | 239 ++++++++++++++++++ 9 files changed, 674 insertions(+) create mode 100644 src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs create mode 100644 src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadIntrospection.cs create mode 100644 src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs create mode 100644 src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs create mode 100644 src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs create mode 100644 test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs new file mode 100644 index 0000000..68b58c8 --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs @@ -0,0 +1,65 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Globalization; + +namespace MicroElements.OpenApi.FluentValidation.FileUpload +{ + /// + /// Builds the human-readable description notes emitted for file-upload constraints, so every OpenAPI + /// backend (Swashbuckle, NSwag, Microsoft.AspNetCore.OpenApi) produces identical wording. + /// + public static class FileUploadDescription + { + /// + /// Formats the file-size note, or returns null when no bounds are configured. + /// + /// File size metadata. + /// A note such as "Maximum file size: 2097152 bytes.", or null. + public static string? FormatSizeNote(IFileSizeValidator meta) + { + long? min = meta.MinSizeBytes; + long? max = meta.MaxSizeBytes; + + if (min is { } minValue && max is { } maxValue) + return $"File size must be between {Format(minValue)} and {Format(maxValue)} bytes."; + + if (max is { } onlyMax) + return $"Maximum file size: {Format(onlyMax)} bytes."; + + if (min is { } onlyMin) + return $"Minimum file size: {Format(onlyMin)} bytes."; + + return null; + } + + /// + /// Formats the allowed-content-types note. + /// + /// Content type metadata. + /// A note such as "Allowed content types: image/jpeg, image/png.". + public static string FormatContentTypeNote(IFileContentTypeValidator meta) + => $"Allowed content types: {string.Join(", ", meta.AllowedContentTypes)}."; + + /// + /// Appends to an existing description, idempotently (a note already present + /// is not duplicated). Preserves any user-authored description text. + /// + /// Existing description (may be null). + /// Note to append. + /// The combined description. + public static string Append(string? existing, string note) + { + if (string.IsNullOrEmpty(existing)) + return note; + + if (existing!.Contains(note, StringComparison.Ordinal)) + return existing; + + return existing + " " + note; + } + + private static string Format(long bytes) => bytes.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadIntrospection.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadIntrospection.cs new file mode 100644 index 0000000..9592690 --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadIntrospection.cs @@ -0,0 +1,49 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using FluentValidation; + +namespace MicroElements.OpenApi.FluentValidation.FileUpload +{ + /// + /// Reads file-upload metadata from a validator using the SAME filtered rule traversal that the schema + /// pipeline uses ( / + /// ). This guarantees that a conditional + /// .FileContentType(...).When(...) rule is included/excluded identically to the size rules — keeping + /// the emitted encoding consistent with the rest of the document (e.g. ). + /// + public static class FileUploadIntrospection + { + /// + /// Enumerates instances declared on the given validator, paired + /// with the resolved member name they are attached to (e.g. File). + /// + /// Validator of the form container type. + /// Form container type. + /// Schema generation options (drives the rule/component filtering). + /// Member name and content-type metadata pairs. + public static IEnumerable<(string MemberName, IFileContentTypeValidator Meta)> GetFileContentTypeValidators( + IValidator validator, + Type schemaType, + ISchemaGenerationOptions options) + { + var typeContext = new TypeContext(schemaType, options); + var validatorContext = new ValidatorContext(typeContext, validator); + + foreach (var ruleContext in validatorContext.GetValidationRules()) + { + var memberName = ruleContext.ValidationRule.PropertyName; + if (string.IsNullOrEmpty(memberName)) + continue; + + foreach (var propertyValidator in ruleContext.GetValidators()) + { + if (propertyValidator is IFileContentTypeValidator meta) + yield return (memberName!, meta); + } + } + } + } +} diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs new file mode 100644 index 0000000..19a78ca --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs @@ -0,0 +1,38 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace MicroElements.OpenApi.FluentValidation.FileUpload +{ + /// + /// Implemented by property validators that restrict the allowed media (content) types of an uploaded file. + /// The OpenAPI layer reads to emit encoding.<part>.contentType + /// for a multipart/form-data request body. + /// + public interface IFileContentTypeValidator + { + /// + /// Gets the allowed media types (e.g. image/jpeg, image/png). + /// + IReadOnlyList AllowedContentTypes { get; } + } + + /// + /// Implemented by property validators that restrict the size (in bytes) of an uploaded file. + /// The OpenAPI layer reads the limits to emit a human-readable description and the + /// x-fileSizeBytes vendor extension on the file property (OpenAPI has no standard byte-size keyword). + /// + public interface IFileSizeValidator + { + /// + /// Gets the minimum allowed file size in bytes, or null when unbounded. + /// + long? MinSizeBytes { get; } + + /// + /// Gets the maximum allowed file size in bytes, or null when unbounded. + /// + long? MaxSizeBytes { get; } + } +} diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs new file mode 100644 index 0000000..008ebaa --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs @@ -0,0 +1,98 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using Microsoft.AspNetCore.Http; + +namespace MicroElements.OpenApi.FluentValidation.FileUpload +{ + /// + /// FluentValidation rule builder extensions for validating uploads. + /// These rules both enforce validation at runtime and surface metadata for OpenAPI generation + /// (allowed media types as encoding.contentType; size limits as a description / vendor extension). + /// + public static class FileUploadValidatorExtensions + { + /// + /// Restricts the allowed media types of the uploaded file. + /// Emits encoding.<part>.contentType on supported OpenAPI backends. + /// + /// Validated object type. + /// Rule builder. + /// Allowed media types (e.g. image/jpeg, image/png). + /// Rule builder options for chaining. + public static IRuleBuilderOptions FileContentType( + this IRuleBuilder ruleBuilder, + params string[] allowedContentTypes) + => ruleBuilder.FileContentType((IEnumerable)allowedContentTypes); + + /// + /// Restricts the allowed media types of the uploaded file. + /// Emits encoding.<part>.contentType on supported OpenAPI backends. + /// + /// Validated object type. + /// Rule builder. + /// Allowed media types. + /// Rule builder options for chaining. + public static IRuleBuilderOptions FileContentType( + this IRuleBuilder ruleBuilder, + IEnumerable allowedContentTypes) + { + if (allowedContentTypes is null) + throw new ArgumentNullException(nameof(allowedContentTypes)); + + var normalized = allowedContentTypes + .Where(contentType => !string.IsNullOrWhiteSpace(contentType)) + .Select(contentType => contentType.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalized.Length == 0) + throw new ArgumentException("At least one non-empty content type must be specified.", nameof(allowedContentTypes)); + + return ruleBuilder.SetValidator(new FileContentTypeValidator(normalized)); + } + + /// + /// Restricts the maximum size (in bytes) of the uploaded file. + /// Emits a description and the x-fileSizeBytes vendor extension (annotation only — not validated by consumers). + /// + /// Validated object type. + /// Rule builder. + /// Maximum size in bytes. + /// Rule builder options for chaining. + public static IRuleBuilderOptions MaxFileSize( + this IRuleBuilder ruleBuilder, + long maxBytes) + => ruleBuilder.SetValidator(new FileSizeValidator(minSizeBytes: null, maxSizeBytes: maxBytes)); + + /// + /// Restricts the minimum size (in bytes) of the uploaded file. + /// + /// Validated object type. + /// Rule builder. + /// Minimum size in bytes. + /// Rule builder options for chaining. + public static IRuleBuilderOptions MinFileSize( + this IRuleBuilder ruleBuilder, + long minBytes) + => ruleBuilder.SetValidator(new FileSizeValidator(minSizeBytes: minBytes, maxSizeBytes: null)); + + /// + /// Restricts the size (in bytes) of the uploaded file to the inclusive range [minBytes, maxBytes]. + /// + /// Validated object type. + /// Rule builder. + /// Minimum size in bytes. + /// Maximum size in bytes. + /// Rule builder options for chaining. + public static IRuleBuilderOptions FileSizeBetween( + this IRuleBuilder ruleBuilder, + long minBytes, + long maxBytes) + => ruleBuilder.SetValidator(new FileSizeValidator(minSizeBytes: minBytes, maxSizeBytes: maxBytes)); + } +} diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs new file mode 100644 index 0000000..5724b68 --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs @@ -0,0 +1,107 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.AspNetCore.Http; + +namespace MicroElements.OpenApi.FluentValidation.FileUpload +{ + /// + /// Validates that an uploaded has one of the allowed media (content) types. + /// A null file passes (composes with NotNull()); enforcement mirrors the documented constraint. + /// + /// Validated object type. + public sealed class FileContentTypeValidator : PropertyValidator, IFileContentTypeValidator + { + /// + /// Initializes a new instance of the class. + /// + /// Allowed media types (already normalized and non-empty). + public FileContentTypeValidator(IReadOnlyList allowedContentTypes) + { + AllowedContentTypes = allowedContentTypes; + } + + /// + public IReadOnlyList AllowedContentTypes { get; } + + /// + public override string Name => "FileContentTypeValidator"; + + /// + public override bool IsValid(ValidationContext context, IFormFile value) + { + if (value is null) + return true; + + foreach (var allowed in AllowedContentTypes) + { + if (string.Equals(allowed, value.ContentType, StringComparison.OrdinalIgnoreCase)) + return true; + } + + context.MessageFormatter.AppendArgument("AllowedContentTypes", string.Join(", ", AllowedContentTypes)); + return false; + } + + /// + protected override string GetDefaultMessageTemplate(string errorCode) + => "'{PropertyName}' must be one of the allowed content types: {AllowedContentTypes}."; + } + + /// + /// Validates that an uploaded size (in bytes) is within the configured bounds. + /// A null file passes (composes with NotNull()). + /// + /// Validated object type. + public sealed class FileSizeValidator : PropertyValidator, IFileSizeValidator + { + /// + /// Initializes a new instance of the class. + /// + /// Minimum size in bytes, or null. + /// Maximum size in bytes, or null. + public FileSizeValidator(long? minSizeBytes, long? maxSizeBytes) + { + MinSizeBytes = minSizeBytes; + MaxSizeBytes = maxSizeBytes; + } + + /// + public long? MinSizeBytes { get; } + + /// + public long? MaxSizeBytes { get; } + + /// + public override string Name => "FileSizeValidator"; + + /// + public override bool IsValid(ValidationContext context, IFormFile value) + { + if (value is null) + return true; + + if (MinSizeBytes is { } min && value.Length < min) + { + context.MessageFormatter.AppendArgument("MinSizeBytes", min); + return false; + } + + if (MaxSizeBytes is { } max && value.Length > max) + { + context.MessageFormatter.AppendArgument("MaxSizeBytes", max); + return false; + } + + return true; + } + + /// + protected override string GetDefaultMessageTemplate(string errorCode) + => "'{PropertyName}' has an invalid file size."; + } +} diff --git a/src/MicroElements.Swashbuckle.FluentValidation/DefaultFluentValidationRuleProvider.cs b/src/MicroElements.Swashbuckle.FluentValidation/DefaultFluentValidationRuleProvider.cs index b7c8719..15e2622 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/DefaultFluentValidationRuleProvider.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/DefaultFluentValidationRuleProvider.cs @@ -7,6 +7,7 @@ using MicroElements.OpenApi; using MicroElements.OpenApi.Core; using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; using Microsoft.Extensions.Options; #if !OPENAPI_V2 using Microsoft.OpenApi.Models; @@ -158,6 +159,28 @@ public IEnumerable> GetRules() } }); + yield return new FluentValidationRule("FileContentType") + .WithCondition(validator => validator is IFileContentTypeValidator) + .WithApply(context => + { + // Content types are emitted as encoding.contentType by the operation filter (machine-readable). + // Also surface them in the description so the constraint is visible in every UI / backend. + var meta = (IFileContentTypeValidator)context.PropertyValidator; + context.Property.Description = FileUploadDescription.Append( + context.Property.Description, FileUploadDescription.FormatContentTypeNote(meta)); + }); + + yield return new FluentValidationRule("FileSize") + .WithCondition(validator => validator is IFileSizeValidator) + .WithApply(context => + { + // OpenAPI has no standard byte-size keyword, so the limit is surfaced as a description note. + var meta = (IFileSizeValidator)context.PropertyValidator; + var note = FileUploadDescription.FormatSizeNote(meta); + if (note != null) + context.Property.Description = FileUploadDescription.Append(context.Property.Description, note); + }); + yield return new FluentValidationRule("Between") .WithCondition(validator => validator is IBetweenValidator) .WithApply(context => diff --git a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs index 5680544..a700b53 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs @@ -31,6 +31,14 @@ public static bool IsStringType(OpenApiSchema schema) #endif } + /// + /// Checks if schema is a binary string (an uploaded file part: type string, format binary). + /// + public static bool IsBinaryFormat(OpenApiSchema schema) + { + return IsStringType(schema) && schema.Format == "binary"; + } + /// /// Checks if schema type is array. /// diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs index 609ff93..2d767c2 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs @@ -5,9 +5,11 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using FluentValidation; using MicroElements.OpenApi; using MicroElements.OpenApi.Core; using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -481,6 +483,51 @@ private void ApplyRulesToRequestBody(OpenApiOperation operation, OperationFilter validator: validator, logger: _logger, schemaGenerationContext: schemaContext); + + // Issue #216: emit encoding.contentType for IFormFile parts restricted via .FileContentType(...). + // Only multipart/form-data carries per-part media types (application/x-www-form-urlencoded does not). + if (string.Equals(contentType.Key, "multipart/form-data", StringComparison.OrdinalIgnoreCase)) + { + ApplyFileContentTypeEncoding(contentType.Value, resolvedSchema, parameterType, validator, context.SchemaRepository); + } + } + } + + /// + /// Issue #216: writes encoding.<part>.contentType for every binary file part that a + /// .FileContentType(...) rule restricts. Part keys are taken verbatim from the rendered schema and + /// matched to the rule name-insensitively. Content types reach this method via the SAME filtered rule + /// traversal the schema pipeline uses, so a conditional rule is included/excluded consistently. + /// + private void ApplyFileContentTypeEncoding( + OpenApiMediaType mediaType, + OpenApiSchema resolvedSchema, + Type parameterType, + IValidator validator, + SchemaRepository schemaRepository) + { + if (resolvedSchema.Properties == null || resolvedSchema.Properties.Count == 0) + return; + + var contentTypeRules = FileUploadIntrospection + .GetFileContentTypeValidators(validator, parameterType, _schemaGenerationOptions) + .ToList(); + if (contentTypeRules.Count == 0) + return; + + foreach (var partKey in resolvedSchema.Properties.Keys.ToArray()) + { + var partSchema = OpenApiSchemaCompatibility.GetProperty(resolvedSchema, partKey, schemaRepository); + if (partSchema == null || !OpenApiSchemaCompatibility.IsBinaryFormat(partSchema)) + continue; + + var match = contentTypeRules.FirstOrDefault(rule => rule.MemberName.EqualsIgnoreAll(partKey)); + var allowed = match.Meta?.AllowedContentTypes; + if (allowed == null || allowed.Count == 0) + continue; + + mediaType.Encoding ??= new Dictionary(); + mediaType.Encoding[partKey] = new OpenApiEncoding { ContentType = string.Join(",", allowed) }; } } } diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs new file mode 100644 index 0000000..5dff0b9 --- /dev/null +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs @@ -0,0 +1,239 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using FluentValidation; +using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +#if OPENAPI_V2 +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif +using Swashbuckle.AspNetCore.SwaggerGen; +using Xunit; + +namespace MicroElements.Swashbuckle.FluentValidation.Tests +{ + /// + /// Issue #216: media type (content type) and file size support for uploads. + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/216 + /// + public class IFormFileMediaTypeTests : UnitTestBase + { + public class UploadProductImageRequest + { + [FromForm(Name = "File")] + public IFormFile File { get; set; } + } + + public class UploadProductImageRequestValidator : AbstractValidator + { + public UploadProductImageRequestValidator() + { + RuleFor(x => x.File) + .NotNull() + .FileContentType("image/jpeg", "image/png") + .MaxFileSize(2 * 1024 * 1024); + } + } + + // --- Reproduction of the original (buggy) behavior reported in the issue ----------------------------- + + public class NestedMemberRulesRequest + { + public IFormFile File { get; set; } + } + + public class NestedMemberRulesValidator : AbstractValidator + { + private static readonly string[] AllowedContentTypes = { "image/jpeg", "image/png" }; + + public NestedMemberRulesValidator() + { + // This is exactly how the issue author wrote the rules. FluentValidation names these rules + // "File.Length" / "File.ContentType", which never match the flat schema property "File", so they + // are silently dropped. This test LOCKS that documented limitation: use the File-level API instead. + RuleFor(x => x.File.Length).GreaterThan(0).LessThanOrEqualTo(2 * 1024 * 1024).When(x => x.File != null); + RuleFor(x => x.File.ContentType).Must(AllowedContentTypes.Contains).When(x => x.File != null); + } + } + + [Fact] + public void Reproduction_Nested_Member_Rules_Are_Silently_Ignored() + { + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(new NestedMemberRulesValidator()) + .GenerateSchema(typeof(NestedMemberRulesRequest), schemaRepository); + + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + var fileProperty = schema.GetProperty(nameof(NestedMemberRulesRequest.File))!; + + // The nested-member rules produce NOTHING in the document (the gap behind issue #216). + fileProperty.Description.Should().BeNullOrEmpty(); + fileProperty.MaxLength.Should().BeNull(); + } + + // --- Schema-level output of the new File-level API (runs on every TFM) ------------------------------ + + [Fact] + public void FileContentType_And_MaxFileSize_Add_Description_To_File_Property() + { + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(new UploadProductImageRequestValidator()) + .GenerateSchema(typeof(UploadProductImageRequest), schemaRepository); + + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + var fileProperty = schema.GetProperty(nameof(UploadProductImageRequest.File))!; + + fileProperty.GetTypeString().Should().Be("string"); + fileProperty.Format.Should().Be("binary"); + fileProperty.Description.Should().Contain("image/jpeg, image/png"); + fileProperty.Description.Should().Contain("2097152"); + // File size must never be expressed as maxLength (that counts characters, not bytes). + fileProperty.MaxLength.Should().BeNull(); + } + + [Fact] + public void NotNull_Only_Does_Not_Emit_Size_Or_ContentType_Notes() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.File).NotNull(); + + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(validator).GenerateSchema(typeof(UploadProductImageRequest), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + var fileProperty = schema.GetProperty(nameof(UploadProductImageRequest.File))!; + + fileProperty.Description.Should().BeNullOrEmpty(); + } + + // --- Operation-level output (encoding.contentType). v1 (Swashbuckle 8/9) object model. --------------- +#if !OPENAPI_V2 + private static (OpenApiOperation Operation, OperationFilterContext Context, OpenApiMediaType MediaType) BuildMultipartOperation( + string contentTypeKey, + SchemaRepository schemaRepository, + SchemaGenerator schemaGenerator, + System.Reflection.MethodInfo methodInfo) + { + var mediaType = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "object", + Properties = new Dictionary + { + ["File"] = new OpenApiSchema { Type = "string", Format = "binary" }, + }, + }, + }; + + var operation = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary { [contentTypeKey] = mediaType }, + }, + }; + + var apiDescription = new ApiDescription(); + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription + { + Name = "File", + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(UploadProductImageRequest)), + Source = BindingSource.Form, + }); + + var context = new OperationFilterContext(apiDescription, schemaGenerator, schemaRepository, methodInfo); + return (operation, context, mediaType); + } + + private static FluentValidationOperationFilter CreateOperationFilter(params IValidator[] validators) + { + var schemaGenerationOptions = new SchemaGenerationOptions + { + NameResolver = new Generation.SystemTextJsonNameResolver(), + SchemaIdSelector = new SchemaGeneratorOptions().SchemaIdSelector, + }; + + var validatorRegistry = new ValidatorRegistry( + validators, + new OptionsWrapper(schemaGenerationOptions)); + + return new FluentValidationOperationFilter( + validatorRegistry: validatorRegistry, + schemaGenerationOptions: new OptionsWrapper(schemaGenerationOptions)); + } + + [Fact] + public void FileContentType_Emits_Encoding_ContentType() + { + var schemaRepository = new SchemaRepository(); + var schemaGenerator = SchemaGenerator(new UploadProductImageRequestValidator()); + var methodInfo = typeof(IFormFileMediaTypeTests).GetMethod(nameof(FileContentType_Emits_Encoding_ContentType))!; + + var (operation, context, mediaType) = BuildMultipartOperation("multipart/form-data", schemaRepository, schemaGenerator, methodInfo); + + CreateOperationFilter(new UploadProductImageRequestValidator()).Apply(operation, context); + + mediaType.Encoding.Should().ContainKey("File"); + mediaType.Encoding["File"].ContentType.Should().Be("image/jpeg,image/png"); + } + + [Fact] + public void Single_ContentType_Emits_Single_Value_Without_Comma() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.File).NotNull().FileContentType("image/png"); + + var schemaRepository = new SchemaRepository(); + var schemaGenerator = SchemaGenerator(validator); + var methodInfo = typeof(IFormFileMediaTypeTests).GetMethod(nameof(Single_ContentType_Emits_Single_Value_Without_Comma))!; + + var (operation, context, mediaType) = BuildMultipartOperation("multipart/form-data", schemaRepository, schemaGenerator, methodInfo); + + CreateOperationFilter(validator).Apply(operation, context); + + mediaType.Encoding["File"].ContentType.Should().Be("image/png"); + } + + [Fact] + public void No_FileContentType_Rule_Emits_No_Encoding() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.File).NotNull(); + + var schemaRepository = new SchemaRepository(); + var schemaGenerator = SchemaGenerator(validator); + var methodInfo = typeof(IFormFileMediaTypeTests).GetMethod(nameof(No_FileContentType_Rule_Emits_No_Encoding))!; + + var (operation, context, mediaType) = BuildMultipartOperation("multipart/form-data", schemaRepository, schemaGenerator, methodInfo); + + CreateOperationFilter(validator).Apply(operation, context); + + (mediaType.Encoding == null || mediaType.Encoding.Count == 0).Should().BeTrue(); + } + + [Fact] + public void Urlencoded_Content_Is_Not_Polluted_With_Encoding() + { + var schemaRepository = new SchemaRepository(); + var schemaGenerator = SchemaGenerator(new UploadProductImageRequestValidator()); + var methodInfo = typeof(IFormFileMediaTypeTests).GetMethod(nameof(Urlencoded_Content_Is_Not_Polluted_With_Encoding))!; + + var (operation, context, mediaType) = BuildMultipartOperation("application/x-www-form-urlencoded", schemaRepository, schemaGenerator, methodInfo); + + CreateOperationFilter(new UploadProductImageRequestValidator()).Apply(operation, context); + + (mediaType.Encoding == null || mediaType.Encoding.Count == 0).Should().BeTrue(); + } +#endif + } +} From d6866f63f7c4c13304bba85aece855f209f595c8 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 19:43:09 +0300 Subject: [PATCH 2/8] feat(nswag): IFormFile media type & size support (#216) - 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) --- ...oElements.Swashbuckle.FluentValidation.sln | 15 +++ samples/SampleNSwagWebApi/Startup.cs | 7 +- .../AspNetCore/ServiceCollectionExtensions.cs | 3 + .../FluentValidationOperationProcessor.cs | 119 +++++++++++++++++ ...icroElements.NSwag.FluentValidation.csproj | 2 + .../NSwagFluentValidationRuleProvider.cs | 25 ++++ ...FluentValidationOperationProcessorTests.cs | 123 ++++++++++++++++++ ...ements.NSwag.FluentValidation.Tests.csproj | 27 ++++ 8 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs create mode 100644 test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs create mode 100644 test/MicroElements.NSwag.FluentValidation.Tests/MicroElements.NSwag.FluentValidation.Tests.csproj diff --git a/MicroElements.Swashbuckle.FluentValidation.sln b/MicroElements.Swashbuckle.FluentValidation.sln index 6fcf35a..80f82fb 100644 --- a/MicroElements.Swashbuckle.FluentValidation.sln +++ b/MicroElements.Swashbuckle.FluentValidation.sln @@ -42,6 +42,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroElements.AspNetCore.OpenApi.FluentValidation.Tests", "test\MicroElements.AspNetCore.OpenApi.FluentValidation.Tests\MicroElements.AspNetCore.OpenApi.FluentValidation.Tests.csproj", "{51A03741-CE69-4834-ADBB-E6532AEF3832}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroElements.NSwag.FluentValidation.Tests", "test\MicroElements.NSwag.FluentValidation.Tests\MicroElements.NSwag.FluentValidation.Tests.csproj", "{B25F7538-AD51-4CEF-A530-389A2E31F575}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -172,6 +174,18 @@ Global {51A03741-CE69-4834-ADBB-E6532AEF3832}.Release|x64.Build.0 = Release|Any CPU {51A03741-CE69-4834-ADBB-E6532AEF3832}.Release|x86.ActiveCfg = Release|Any CPU {51A03741-CE69-4834-ADBB-E6532AEF3832}.Release|x86.Build.0 = Release|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Debug|x64.ActiveCfg = Debug|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Debug|x64.Build.0 = Debug|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Debug|x86.ActiveCfg = Debug|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Debug|x86.Build.0 = Debug|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Release|Any CPU.Build.0 = Release|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Release|x64.ActiveCfg = Release|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Release|x64.Build.0 = Release|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Release|x86.ActiveCfg = Release|Any CPU + {B25F7538-AD51-4CEF-A530-389A2E31F575}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -183,6 +197,7 @@ Global {FC318D02-FA03-4D3E-92F8-A37E41947DC9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {1BD071E8-AB99-4DD7-B4AD-2D7BBA1848FD} = {9ED7D819-FC90-4504-A46D-D38E3BE107B7} {51A03741-CE69-4834-ADBB-E6532AEF3832} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {B25F7538-AD51-4CEF-A530-389A2E31F575} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1AA0A677-C642-44C8-A6CE-495E7B7074B8} diff --git a/samples/SampleNSwagWebApi/Startup.cs b/samples/SampleNSwagWebApi/Startup.cs index f0f314f..f0c8ef1 100644 --- a/samples/SampleNSwagWebApi/Startup.cs +++ b/samples/SampleNSwagWebApi/Startup.cs @@ -18,10 +18,15 @@ public void ConfigureServices(IServiceCollection services) services.AddOpenApiDocument((settings, serviceProvider) => { - var fluentValidationSchemaProcessor = serviceProvider.CreateScope().ServiceProvider.GetService(); + var scopedProvider = serviceProvider.CreateScope().ServiceProvider; // Add the fluent validations schema processor + var fluentValidationSchemaProcessor = scopedProvider.GetService(); settings.SchemaSettings.SchemaProcessors.Add(fluentValidationSchemaProcessor); + + // Issue #216: add the operation processor that emits multipart/form-data file content types + var fluentValidationOperationProcessor = scopedProvider.GetService(); + settings.OperationProcessors.Add(fluentValidationOperationProcessor); }); // Register FV validators diff --git a/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs b/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs index 5a988db..72770c1 100644 --- a/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs +++ b/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs @@ -32,6 +32,9 @@ public static IServiceCollection AddFluentValidationRulesToSwagger( // Add the FluentValidationSchemaProcessor as a scoped service services.AddScoped(); + // Issue #216: Add the FluentValidationOperationProcessor (emits multipart/form-data file content types). + services.AddScoped(); + // Adds default IValidatorRegistry services.TryAdd(new ServiceDescriptor(typeof(IValidatorRegistry), typeof(ServiceProviderValidatorRegistry), registrationOptions.ServiceLifetime)); diff --git a/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs new file mode 100644 index 0000000..dcddbf6 --- /dev/null +++ b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs @@ -0,0 +1,119 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using MicroElements.OpenApi.Core; +using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NJsonSchema; +using NSwag; +using NSwag.Generation.Processors; +using NSwag.Generation.Processors.Contexts; + +namespace MicroElements.NSwag.FluentValidation +{ + /// + /// NSwag that emits multipart/form-data encoding for + /// IFormFile parts restricted via .FileContentType(...). + /// + /// Note: NSwag's OpenApiEncoding.EncodingType serializes as encodingType rather than the + /// OpenAPI-spec contentType (a known NSwag limitation through at least 14.7.x). The same allowed + /// content types are also written to the file part's description by the schema rule, guaranteeing the + /// information is visible regardless of the encoding key name. + /// + /// + public class FluentValidationOperationProcessor : IOperationProcessor + { + private const string MultipartContentType = "multipart/form-data"; + + private readonly ILogger _logger; + private readonly IValidatorRegistry? _validatorRegistry; + private readonly SchemaGenerationOptions _schemaGenerationOptions; + + /// + /// Initializes a new instance of the class. + /// + /// for logging. Can be null. + /// Gets validators for a particular type. + /// Schema generation options. + public FluentValidationOperationProcessor( + ILoggerFactory? loggerFactory = null, + IValidatorRegistry? validatorRegistry = null, + IOptions? schemaGenerationOptions = null) + { + _logger = loggerFactory?.CreateLogger(typeof(FluentValidationOperationProcessor)) ?? NullLogger.Instance; + _validatorRegistry = validatorRegistry; + _schemaGenerationOptions = schemaGenerationOptions?.Value ?? new SchemaGenerationOptions(); + } + + /// + public bool Process(OperationProcessorContext context) + { + try + { + ApplyEncoding(context); + } + catch (Exception e) + { + _logger.LogWarning(0, e, "Error applying FluentValidation file content types to operation."); + } + + // Always keep the operation in the document. + return true; + } + + private void ApplyEncoding(OperationProcessorContext context) + { + if (_validatorRegistry == null || context.MethodInfo == null) + return; + + var requestBody = context.OperationDescription.Operation.RequestBody; + if (requestBody?.Content == null) + return; + + if (!requestBody.Content.TryGetValue(MultipartContentType, out var media) || media?.Schema == null) + return; + + var schema = media.Schema.ActualSchema; + if (schema.ActualProperties == null || schema.ActualProperties.Count == 0) + return; + + foreach (var parameter in context.MethodInfo.GetParameters()) + { + var validator = _validatorRegistry.GetValidator(parameter.ParameterType); + if (validator == null) + continue; + + var contentTypeRules = FileUploadIntrospection + .GetFileContentTypeValidators(validator, parameter.ParameterType, _schemaGenerationOptions) + .ToList(); + if (contentTypeRules.Count == 0) + continue; + + foreach (var part in schema.ActualProperties) + { + var partSchema = part.Value.ActualSchema; + if (!partSchema.Type.HasFlag(JsonObjectType.String) || partSchema.Format != "binary") + continue; + + var match = contentTypeRules.FirstOrDefault(rule => rule.MemberName.EqualsIgnoreAll(part.Key)); + var allowed = match.Meta?.AllowedContentTypes; + if (allowed == null || allowed.Count == 0) + continue; + + if (!media.Encoding.TryGetValue(part.Key, out var encoding) || encoding == null) + { + encoding = new OpenApiEncoding(); + media.Encoding[part.Key] = encoding; + } + + encoding.EncodingType = string.Join(",", allowed); + } + } + } + } +} diff --git a/src/MicroElements.NSwag.FluentValidation/MicroElements.NSwag.FluentValidation.csproj b/src/MicroElements.NSwag.FluentValidation/MicroElements.NSwag.FluentValidation.csproj index 18bf7cb..fbc2111 100644 --- a/src/MicroElements.NSwag.FluentValidation/MicroElements.NSwag.FluentValidation.csproj +++ b/src/MicroElements.NSwag.FluentValidation/MicroElements.NSwag.FluentValidation.csproj @@ -15,6 +15,8 @@ + + diff --git a/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs b/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs index f342db2..71a7558 100644 --- a/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs +++ b/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs @@ -7,6 +7,7 @@ using FluentValidation.Validators; using MicroElements.OpenApi.Core; using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; using Microsoft.Extensions.Options; using NJsonSchema; using NJsonSchema.Generation; @@ -238,6 +239,30 @@ public FluentValidationRule[] CreateDefaultRules() schema.Properties[context.PropertyKey].Pattern = "^[^@]+@[^@]+$"; // [^@] All chars except @ }, }, + new FluentValidationRule("FileContentType") + { + // Content types are also emitted as encoding.contentType by FluentValidationOperationProcessor. + // The description ensures the constraint is visible even where encoding is not consumed. + Matches = propertyValidator => propertyValidator is IFileContentTypeValidator, + Apply = context => + { + var meta = (IFileContentTypeValidator) context.PropertyValidator; + if (context.Schema.Schema.Properties.TryGetValue(context.PropertyKey, out var property)) + property.Description = FileUploadDescription.Append(property.Description, FileUploadDescription.FormatContentTypeNote(meta)); + }, + }, + new FluentValidationRule("FileSize") + { + // OpenAPI has no standard byte-size keyword, so the limit is surfaced as a description note. + Matches = propertyValidator => propertyValidator is IFileSizeValidator, + Apply = context => + { + var meta = (IFileSizeValidator) context.PropertyValidator; + var note = FileUploadDescription.FormatSizeNote(meta); + if (note != null && context.Schema.Schema.Properties.TryGetValue(context.PropertyKey, out var property)) + property.Description = FileUploadDescription.Append(property.Description, note); + }, + }, }; } diff --git a/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs b/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs new file mode 100644 index 0000000..704646a --- /dev/null +++ b/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using FluentAssertions; +using FluentValidation; +using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NJsonSchema; +using NJsonSchema.Generation; +using NSwag; +using NSwag.Generation; +using NSwag.Generation.Processors.Contexts; +using Xunit; + +namespace MicroElements.NSwag.FluentValidation.Tests +{ + /// + /// Issue #216: NSwag emits multipart/form-data file content types via the operation processor. + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/216 + /// + public class FluentValidationOperationProcessorTests + { + public class UploadProductImageRequest + { + [FromForm(Name = "File")] + public IFormFile File { get; set; } = default!; + } + + public class UploadProductImageRequestValidator : AbstractValidator + { + public UploadProductImageRequestValidator() + { + RuleFor(x => x.File) + .NotNull() + .FileContentType("image/jpeg", "image/png") + .MaxFileSize(2 * 1024 * 1024); + } + } + + // The processor inspects the action method parameters to resolve the form container type. + public static void Upload([FromForm] UploadProductImageRequest request) + { + } + + private static (OperationProcessorContext Context, OpenApiMediaType MediaType) BuildContext(string contentTypeKey) + { + var fileSchema = new JsonSchemaProperty { Type = JsonObjectType.String, Format = "binary" }; + var formSchema = new JsonSchema { Type = JsonObjectType.Object }; + formSchema.Properties["File"] = fileSchema; + + var mediaType = new OpenApiMediaType { Schema = formSchema }; + + var operation = new OpenApiOperation { RequestBody = new OpenApiRequestBody() }; + operation.RequestBody.Content[contentTypeKey] = mediaType; + + var document = new OpenApiDocument(); + var settings = new OpenApiDocumentGeneratorSettings(); + var resolver = new JsonSchemaResolver(document, settings.SchemaSettings); + var generator = new OpenApiDocumentGenerator(settings, resolver); + var operationDescription = new OpenApiOperationDescription { Operation = operation }; + var methodInfo = typeof(FluentValidationOperationProcessorTests).GetMethod(nameof(Upload))!; + + var context = new OperationProcessorContext( + document, + operationDescription, + typeof(FluentValidationOperationProcessorTests), + methodInfo, + generator, + resolver, + settings, + new List()); + + return (context, mediaType); + } + + private static FluentValidationOperationProcessor CreateProcessor(params IValidator[] validators) + { + var options = Options.Create(new SchemaGenerationOptions()); + var validatorRegistry = new ValidatorRegistry(validators, options); + return new FluentValidationOperationProcessor(validatorRegistry: validatorRegistry, schemaGenerationOptions: options); + } + + [Fact] + public void FileContentType_Emits_Encoding_For_File_Part() + { + var (context, mediaType) = BuildContext("multipart/form-data"); + + CreateProcessor(new UploadProductImageRequestValidator()).Process(context); + + mediaType.Encoding.Should().ContainKey("File"); + // NSwag serializes EncodingType as the "encodingType" JSON field (a known NSwag limitation); + // the value still carries the comma-joined allowed media types. + mediaType.Encoding["File"].EncodingType.Should().Be("image/jpeg,image/png"); + } + + [Fact] + public void Urlencoded_Body_Is_Not_Given_Encoding() + { + var (context, mediaType) = BuildContext("application/x-www-form-urlencoded"); + + CreateProcessor(new UploadProductImageRequestValidator()).Process(context); + + mediaType.Encoding.Should().BeEmpty(); + } + + [Fact] + public void No_FileContentType_Rule_Emits_No_Encoding() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.File).NotNull(); + + var (context, mediaType) = BuildContext("multipart/form-data"); + + CreateProcessor(validator).Process(context); + + mediaType.Encoding.Should().BeEmpty(); + } + } +} diff --git a/test/MicroElements.NSwag.FluentValidation.Tests/MicroElements.NSwag.FluentValidation.Tests.csproj b/test/MicroElements.NSwag.FluentValidation.Tests/MicroElements.NSwag.FluentValidation.Tests.csproj new file mode 100644 index 0000000..6d74854 --- /dev/null +++ b/test/MicroElements.NSwag.FluentValidation.Tests/MicroElements.NSwag.FluentValidation.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0;net9.0 + enable + latest + false + false + + + + + + + + + + + + + + + + + + + From d99c49af0f8309af891c497b8e3144a860dcac99 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 19:46:21 +0300 Subject: [PATCH 3/8] feat(aspnetcore-openapi,docs): file size/content-type description; bump 7.2.0 (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CHANGELOG.md | 10 ++++ README.md | 52 +++++++++++++++++++ .../DefaultFluentValidationRuleProvider.cs | 22 ++++++++ version.props | 2 +- 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 157e18c..c2fc092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Changes in 7.2.0 +- Added: media type (content type) and file size validation for `IFormFile` uploads (Issue #216) + - New File-level FluentValidation rules in `MicroElements.OpenApi.FluentValidation` (namespace `MicroElements.OpenApi.FluentValidation.FileUpload`): `.FileContentType(params string[])`, `.MaxFileSize(long)`, `.MinFileSize(long)`, `.FileSizeBetween(long, long)` on `IRuleBuilder`. They both enforce validation at runtime and surface metadata for OpenAPI generation + - Root cause: rules on nested `IFormFile` members (`RuleFor(x => x.File.Length)` / `RuleFor(x => x.File.ContentType)`) are named `File.Length` / `File.ContentType` and never match the flat schema property `File`, so they were silently dropped; and `Must(...)` is opaque so allowed content types could not be reflected. Use the new File-level rules instead + - **Swashbuckle**: emits `requestBody.content["multipart/form-data"].encoding..contentType` (comma-joined allowed types) and appends the allowed types and size limits to the file property `description`. File size is never emitted as `maxLength` (which counts characters, not bytes). Works on net8.0/net9.0 (Microsoft.OpenApi v1, OpenAPI 3.0) and net10.0 (Microsoft.OpenApi v2, OpenAPI 3.1) + - **NSwag**: a new `FluentValidationOperationProcessor` (`IOperationProcessor`) emits multipart encoding for file parts; the allowed types and size limits are also appended to the file part `description`. Register it alongside the schema processor: `settings.OperationProcessors.Add(serviceProvider.GetService())`. Known NSwag limitation: `OpenApiEncoding.EncodingType` serializes as `encodingType` rather than the OpenAPI-spec `contentType` (through at least NSwag 14.7.x), so the `description` is the guaranteed-visible carrier + - **Microsoft.AspNetCore.OpenApi**: the allowed types and size limits are appended to the file property `description`. `encoding.contentType` is out of scope for this backend (its operation transformer has no mutable multipart request-body path) + - Purely additive / opt-in: behavior only changes when the new rules are used; no existing document output changes + - File size has no standard OpenAPI/JSON-Schema byte keyword, so it is documented in the `description` (annotation only; enforcement stays server-side via FluentValidation) + # Changes in 7.1.7 - Fixed: The nested `[FromQuery]` fixes (#209 + #211) now also apply to the native `Microsoft.AspNetCore.OpenApi` transformer and the experimental Swashbuckle DocumentFilter (Issue #213) - `FluentValidationOperationTransformer` (package `MicroElements.AspNetCore.OpenApi.FluentValidation`) previously set a nested parameter `required` from the leaf validator alone — ignoring both whether the `SetValidator`/`ChildRules` chain reaches the leaf (#211) and whether every ancestor of the dot-path is required (#209). It now follows the same reachability + ancestor-required rules as the Swashbuckle `OperationFilter` diff --git a/README.md b/README.md index 1716b56..c262cb5 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,58 @@ See sample project: https://github.com/micro-elements/MicroElements.Swashbuckle. * IComparisonValidator (GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual) * IBetweenValidator (InclusiveBetween, ExclusiveBetween) +## File uploads (media types & size) — Issue #216 + +Validation rules written on nested `IFormFile` members (e.g. `RuleFor(x => x.File.ContentType)` / +`RuleFor(x => x.File.Length)`) are **not** reflected in the OpenAPI document: FluentValidation names them +`File.ContentType` / `File.Length`, which never match the flat `File` schema property, and `Must(...)` carries +no introspectable metadata. Use the dedicated File-level rules instead: + +```csharp +using MicroElements.OpenApi.FluentValidation.FileUpload; + +public class UploadProductImageRequestValidator : AbstractValidator +{ + public UploadProductImageRequestValidator() + { + RuleFor(x => x.File) + .NotNull() // required + .FileContentType("image/jpeg", "image/png") // allowed media types + .MaxFileSize(2 * 1024 * 1024); // 2 MB + } +} +``` + +These rules enforce the constraints at runtime **and** drive the OpenAPI output: + +```yaml +multipart/form-data: + schema: + properties: + File: + type: string + format: binary + description: "Allowed content types: image/jpeg, image/png. Maximum file size: 2097152 bytes." + encoding: + File: + contentType: "image/jpeg,image/png" +``` + +Available rules: `.FileContentType(params string[])`, `.MaxFileSize(long)`, `.MinFileSize(long)`, +`.FileSizeBetween(long, long)`. + +Backend support: + +| Backend | `encoding.contentType` | size & content types in `description` | +|---|---|---| +| Swashbuckle | ✅ (net8/9 = OpenAPI 3.0; net10 = OpenAPI 3.1) | ✅ | +| NSwag | ✅ via `FluentValidationOperationProcessor` (serialized as `encodingType` — a known NSwag limitation) | ✅ | +| Microsoft.AspNetCore.OpenApi | ❌ (out of scope — structural) | ✅ | + +Notes: +- File **size** has no standard OpenAPI/JSON-Schema byte keyword, so it is documented in `description` only (annotation, not enforced by consumers; enforcement stays server-side via FluentValidation). +- NSwag requires registering the operation processor: `settings.OperationProcessors.Add(serviceProvider.GetService())` (see the NSwag sample). + ## Extensibility You can register FluentValidationRule in ServiceCollection. diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs index 40f9079..d176d58 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/DefaultFluentValidationRuleProvider.cs @@ -7,6 +7,7 @@ using MicroElements.OpenApi; using MicroElements.OpenApi.Core; using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; using Microsoft.Extensions.Options; #if !OPENAPI_V2 using Microsoft.OpenApi.Models; @@ -158,6 +159,27 @@ public IEnumerable> GetRules() } }); + yield return new FluentValidationRule("FileContentType") + .WithCondition(validator => validator is IFileContentTypeValidator) + .WithApply(context => + { + // Encoding.contentType is out of scope for this backend (the operation transformer cannot + // mutate the multipart request body); surface the allowed types in the description instead. + var meta = (IFileContentTypeValidator)context.PropertyValidator; + context.Property.Description = FileUploadDescription.Append( + context.Property.Description, FileUploadDescription.FormatContentTypeNote(meta)); + }); + + yield return new FluentValidationRule("FileSize") + .WithCondition(validator => validator is IFileSizeValidator) + .WithApply(context => + { + var meta = (IFileSizeValidator)context.PropertyValidator; + var note = FileUploadDescription.FormatSizeNote(meta); + if (note != null) + context.Property.Description = FileUploadDescription.Append(context.Property.Description, note); + }); + yield return new FluentValidationRule("Between") .WithCondition(validator => validator is IBetweenValidator) .WithApply(context => diff --git a/version.props b/version.props index f7ed6f5..2d490db 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.1.7 + 7.2.0 From a7e665d81699af4ed355f504f3c5ad0fea7829cd Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 19:57:21 +0300 Subject: [PATCH 4/8] test(aspnetcore-openapi): verify IFormFile content-type/size in document (#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) --- README.md | 11 +++-- .../Issue216SpikeTests.cs | 44 +++++++++++++++++++ .../Program.cs | 1 + .../TestModels.cs | 20 +++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs diff --git a/README.md b/README.md index c262cb5..bfffef8 100644 --- a/README.md +++ b/README.md @@ -224,15 +224,18 @@ Available rules: `.FileContentType(params string[])`, `.MaxFileSize(long)`, `.Mi Backend support: -| Backend | `encoding.contentType` | size & content types in `description` | +| Backend | size & content types in `description` | machine-readable `encoding.contentType` | |---|---|---| -| Swashbuckle | ✅ (net8/9 = OpenAPI 3.0; net10 = OpenAPI 3.1) | ✅ | -| NSwag | ✅ via `FluentValidationOperationProcessor` (serialized as `encodingType` — a known NSwag limitation) | ✅ | -| Microsoft.AspNetCore.OpenApi | ❌ (out of scope — structural) | ✅ | +| Swashbuckle | ✅ | ✅ (net8/9 = OpenAPI 3.0; net10 = OpenAPI 3.1) | +| NSwag | ✅ | ✅ via `FluentValidationOperationProcessor` (serialized as `encodingType` — a known NSwag limitation) | +| Microsoft.AspNetCore.OpenApi | ✅ | ❌ not emitted (see note) | + +The issue scenario — making the generated OpenAPI document reflect the allowed content types and size limit — works on **all three** backends via the file part `description`. Only the extra machine-readable `encoding.contentType` field differs. Notes: - File **size** has no standard OpenAPI/JSON-Schema byte keyword, so it is documented in `description` only (annotation, not enforced by consumers; enforcement stays server-side via FluentValidation). - NSwag requires registering the operation processor: `settings.OperationProcessors.Add(serviceProvider.GetService())` (see the NSwag sample). +- Microsoft.AspNetCore.OpenApi: `encoding.contentType` is not emitted — its `IOpenApiOperationTransformer` does not write the multipart request body, and on net9 the transformer context cannot resolve a `$ref`'d form schema. On net10 the file part is emitted as a `$ref` to a shared `IFormFile` component, so the `description` is shared across all `IFormFile` endpoints (differing per-endpoint content-type rules would accumulate on that one component). ## Extensibility diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs new file mode 100644 index 0000000..cd75292 --- /dev/null +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace MicroElements.AspNetCore.OpenApi.FluentValidation.Tests; + +/// +/// Issue #216: IFormFile media type / size for the native Microsoft.AspNetCore.OpenApi backend. +/// The allowed content types and size limit are documented on the file part description. +/// On net9 the multipart schema is inlined (description on the file property); on net10 the file part +/// is a $ref to a shared IFormFile component (description on that component). +/// Encoding.contentType is NOT emitted on this backend. +/// +public class Issue216SpikeTests : IClassFixture +{ + private readonly AspNetCoreOpenApiTests.TestWebApplicationFactory _factory; + + public Issue216SpikeTests(AspNetCoreOpenApiTests.TestWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task FileContentType_And_MaxFileSize_Are_Documented_For_Upload_Endpoint() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/openapi/v1.json"); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + + // The /api/upload endpoint exists with a multipart/form-data request body. + using var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("paths").GetProperty("/api/upload") + .GetProperty("post").GetProperty("requestBody") + .GetProperty("content").TryGetProperty("multipart/form-data", out _) + .Should().BeTrue(); + + // The allowed content types and the size limit are documented (inline on net9, on the shared + // IFormFile component on net10) — verified at document level so it is robust to inline-vs-$ref. + json.Should().Contain("Allowed content types: image/jpeg, image/png"); + json.Should().Contain("Maximum file size: 2097152 bytes"); + } +} diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs index 2de9101..06f5611 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs @@ -26,5 +26,6 @@ app.MapPost("/api/request", (TestRequestWithNested dto) => Results.Ok(dto)); app.MapPost("/api/collections", (TestCollectionModel model) => Results.Ok(model)); app.MapPost("/api/password", (TestPasswordModel model) => Results.Ok(model)); +app.MapPost("/api/upload", ([Microsoft.AspNetCore.Mvc.FromForm] UploadImageRequest request) => Results.Ok()).DisableAntiforgery(); app.Run(); diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs index eb26ae0..87f8215 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs @@ -3,10 +3,30 @@ using System.Numerics; using FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; +using Microsoft.AspNetCore.Http; // Marker class for WebApplicationFactory public class TestMarker; +// ----- Issue #216: file upload spike model ----- + +public class UploadImageRequest +{ + public IFormFile File { get; set; } = default!; +} + +public class UploadImageRequestValidator : AbstractValidator +{ + public UploadImageRequestValidator() + { + RuleFor(x => x.File) + .NotNull() + .FileContentType("image/jpeg", "image/png") + .MaxFileSize(2 * 1024 * 1024); + } +} + // ----- Models ----- public class TestCustomer From 349d59071cea8dde72b52f3fc1720fd9d51ea5e4 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 20:09:00 +0300 Subject: [PATCH 5/8] refactor(#216): simplify file-upload encoding emission (cleanup, no behavior 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) --- .../FluentValidationOperationProcessor.cs | 11 +++++------ .../FileUpload/FileUploadDescription.cs | 2 +- .../FluentValidation/FileUpload/FileUploadMetadata.cs | 4 ++-- .../Swashbuckle/FluentValidationOperationFilter.cs | 8 +++++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs index dcddbf6..bff3d11 100644 --- a/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs +++ b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs @@ -100,16 +100,15 @@ private void ApplyEncoding(OperationProcessorContext context) if (!partSchema.Type.HasFlag(JsonObjectType.String) || partSchema.Format != "binary") continue; - var match = contentTypeRules.FirstOrDefault(rule => rule.MemberName.EqualsIgnoreAll(part.Key)); - var allowed = match.Meta?.AllowedContentTypes; + var allowed = contentTypeRules + .Where(rule => rule.MemberName.EqualsIgnoreAll(part.Key)) + .Select(rule => rule.Meta.AllowedContentTypes) + .FirstOrDefault(); if (allowed == null || allowed.Count == 0) continue; if (!media.Encoding.TryGetValue(part.Key, out var encoding) || encoding == null) - { - encoding = new OpenApiEncoding(); - media.Encoding[part.Key] = encoding; - } + media.Encoding[part.Key] = encoding = new OpenApiEncoding(); encoding.EncodingType = string.Join(",", allowed); } diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs index 68b58c8..ea578bd 100644 --- a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs @@ -54,7 +54,7 @@ public static string Append(string? existing, string note) if (string.IsNullOrEmpty(existing)) return note; - if (existing!.Contains(note, StringComparison.Ordinal)) + if (existing.Contains(note, StringComparison.Ordinal)) return existing; return existing + " " + note; diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs index 19a78ca..a297173 100644 --- a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadMetadata.cs @@ -20,8 +20,8 @@ public interface IFileContentTypeValidator /// /// Implemented by property validators that restrict the size (in bytes) of an uploaded file. - /// The OpenAPI layer reads the limits to emit a human-readable description and the - /// x-fileSizeBytes vendor extension on the file property (OpenAPI has no standard byte-size keyword). + /// The OpenAPI layer reads the limits to emit a human-readable description on the file property + /// (OpenAPI has no standard byte-size keyword). /// public interface IFileSizeValidator { diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs index 2d767c2..2d2e3c5 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs @@ -515,14 +515,16 @@ private void ApplyFileContentTypeEncoding( if (contentTypeRules.Count == 0) return; - foreach (var partKey in resolvedSchema.Properties.Keys.ToArray()) + foreach (var partKey in resolvedSchema.Properties.Keys) { var partSchema = OpenApiSchemaCompatibility.GetProperty(resolvedSchema, partKey, schemaRepository); if (partSchema == null || !OpenApiSchemaCompatibility.IsBinaryFormat(partSchema)) continue; - var match = contentTypeRules.FirstOrDefault(rule => rule.MemberName.EqualsIgnoreAll(partKey)); - var allowed = match.Meta?.AllowedContentTypes; + var allowed = contentTypeRules + .Where(rule => rule.MemberName.EqualsIgnoreAll(partKey)) + .Select(rule => rule.Meta.AllowedContentTypes) + .FirstOrDefault(); if (allowed == null || allowed.Count == 0) continue; From c6fab0cee3164eb754fbcbf60848ecd53a97219c Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 20:30:24 +0300 Subject: [PATCH 6/8] =?UTF-8?q?fix(#216):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20error=20message,=20fail-fast,=20tests,=20comma=20sp?= =?UTF-8?q?acing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 2 +- .../FluentValidationOperationProcessor.cs | 2 +- .../FileUploadValidatorExtensions.cs | 7 ++++- .../FileUpload/FileUploadValidators.cs | 29 ++++++++++-------- .../FluentValidationOperationFilter.cs | 2 +- ...FluentValidationOperationProcessorTests.cs | 2 +- .../IFormFileMediaTypeTests.cs | 30 ++++++++++++++++++- 7 files changed, 56 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index bfffef8..26233a8 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ multipart/form-data: description: "Allowed content types: image/jpeg, image/png. Maximum file size: 2097152 bytes." encoding: File: - contentType: "image/jpeg,image/png" + contentType: "image/jpeg, image/png" ``` Available rules: `.FileContentType(params string[])`, `.MaxFileSize(long)`, `.MinFileSize(long)`, diff --git a/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs index bff3d11..1cbeef6 100644 --- a/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs +++ b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs @@ -110,7 +110,7 @@ private void ApplyEncoding(OperationProcessorContext context) if (!media.Encoding.TryGetValue(part.Key, out var encoding) || encoding == null) media.Encoding[part.Key] = encoding = new OpenApiEncoding(); - encoding.EncodingType = string.Join(",", allowed); + encoding.EncodingType = string.Join(", ", allowed); } } } diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs index 008ebaa..ee9db10 100644 --- a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs @@ -93,6 +93,11 @@ public static IRuleBuilderOptions FileSizeBetween( this IRuleBuilder ruleBuilder, long minBytes, long maxBytes) - => ruleBuilder.SetValidator(new FileSizeValidator(minSizeBytes: minBytes, maxSizeBytes: maxBytes)); + { + if (maxBytes < minBytes) + throw new ArgumentException($"maxBytes ({maxBytes}) must be greater than or equal to minBytes ({minBytes}).", nameof(maxBytes)); + + return ruleBuilder.SetValidator(new FileSizeValidator(minSizeBytes: minBytes, maxSizeBytes: maxBytes)); + } } } diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs index 5724b68..205e2f7 100644 --- a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs @@ -85,23 +85,28 @@ public override bool IsValid(ValidationContext context, IFormFile value) if (value is null) return true; - if (MinSizeBytes is { } min && value.Length < min) - { - context.MessageFormatter.AppendArgument("MinSizeBytes", min); - return false; - } + var tooSmall = MinSizeBytes is { } min && value.Length < min; + var tooLarge = MaxSizeBytes is { } max && value.Length > max; + if (!tooSmall && !tooLarge) + return true; - if (MaxSizeBytes is { } max && value.Length > max) - { - context.MessageFormatter.AppendArgument("MaxSizeBytes", max); - return false; - } + // Surface both configured bounds so the (context-aware) message template can render them. + if (MinSizeBytes.HasValue) + context.MessageFormatter.AppendArgument("MinSizeBytes", MinSizeBytes.Value); + if (MaxSizeBytes.HasValue) + context.MessageFormatter.AppendArgument("MaxSizeBytes", MaxSizeBytes.Value); - return true; + return false; } /// protected override string GetDefaultMessageTemplate(string errorCode) - => "'{PropertyName}' has an invalid file size."; + { + if (MinSizeBytes.HasValue && MaxSizeBytes.HasValue) + return "'{PropertyName}' must be between {MinSizeBytes} and {MaxSizeBytes} bytes."; + if (MaxSizeBytes.HasValue) + return "'{PropertyName}' must not exceed {MaxSizeBytes} bytes."; + return "'{PropertyName}' must be at least {MinSizeBytes} bytes."; + } } } diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs index 2d2e3c5..a6615af 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs @@ -529,7 +529,7 @@ private void ApplyFileContentTypeEncoding( continue; mediaType.Encoding ??= new Dictionary(); - mediaType.Encoding[partKey] = new OpenApiEncoding { ContentType = string.Join(",", allowed) }; + mediaType.Encoding[partKey] = new OpenApiEncoding { ContentType = string.Join(", ", allowed) }; } } } diff --git a/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs b/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs index 704646a..6eb5f64 100644 --- a/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs +++ b/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs @@ -94,7 +94,7 @@ public void FileContentType_Emits_Encoding_For_File_Part() mediaType.Encoding.Should().ContainKey("File"); // NSwag serializes EncodingType as the "encodingType" JSON field (a known NSwag limitation); // the value still carries the comma-joined allowed media types. - mediaType.Encoding["File"].EncodingType.Should().Be("image/jpeg,image/png"); + mediaType.Encoding["File"].EncodingType.Should().Be("image/jpeg, image/png"); } [Fact] diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs index 5dff0b9..fd2a29e 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs @@ -115,6 +115,34 @@ public void NotNull_Only_Does_Not_Emit_Size_Or_ContentType_Notes() fileProperty.Description.Should().BeNullOrEmpty(); } + [Fact] + public void MinFileSize_Adds_Minimum_Size_Description() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.File).MinFileSize(1024); + + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(validator).GenerateSchema(typeof(UploadProductImageRequest), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + var fileProperty = schema.GetProperty(nameof(UploadProductImageRequest.File))!; + + fileProperty.Description.Should().Contain("Minimum file size: 1024 bytes"); + } + + [Fact] + public void FileSizeBetween_Adds_Range_Description() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.File).FileSizeBetween(1024, 2 * 1024 * 1024); + + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(validator).GenerateSchema(typeof(UploadProductImageRequest), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + var fileProperty = schema.GetProperty(nameof(UploadProductImageRequest.File))!; + + fileProperty.Description.Should().Contain("File size must be between 1024 and 2097152 bytes"); + } + // --- Operation-level output (encoding.contentType). v1 (Swashbuckle 8/9) object model. --------------- #if !OPENAPI_V2 private static (OpenApiOperation Operation, OperationFilterContext Context, OpenApiMediaType MediaType) BuildMultipartOperation( @@ -184,7 +212,7 @@ public void FileContentType_Emits_Encoding_ContentType() CreateOperationFilter(new UploadProductImageRequestValidator()).Apply(operation, context); mediaType.Encoding.Should().ContainKey("File"); - mediaType.Encoding["File"].ContentType.Should().Be("image/jpeg,image/png"); + mediaType.Encoding["File"].ContentType.Should().Be("image/jpeg, image/png"); } [Fact] From 004a4312b5d769cf1e2c138838cbbec800620ca4 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 20:36:44 +0300 Subject: [PATCH 7/8] test(#216): cover FileSizeBetween inverted-bounds guard 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) --- .../IFormFileMediaTypeTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs index fd2a29e..638fe40 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs @@ -143,6 +143,16 @@ public void FileSizeBetween_Adds_Range_Description() fileProperty.Description.Should().Contain("File size must be between 1024 and 2097152 bytes"); } + [Fact] + public void FileSizeBetween_Throws_When_Bounds_Inverted() + { + var validator = new InlineValidator(); + System.Action act = () => validator.RuleFor(x => x.File).FileSizeBetween(2 * 1024 * 1024, 1024); + + act.Should().Throw() + .WithMessage("*maxBytes*must be greater than or equal to*minBytes*"); + } + // --- Operation-level output (encoding.contentType). v1 (Swashbuckle 8/9) object model. --------------- #if !OPENAPI_V2 private static (OpenApiOperation Operation, OperationFilterContext Context, OpenApiMediaType MediaType) BuildMultipartOperation( From 2d60ee1ff6003f2f51a161907d55f473a1c61173 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 20 Jun 2026 20:40:06 +0300 Subject: [PATCH 8/8] release: 7.1.8-beta.1 (Issue #216 IFormFile media type & size) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- version.props | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fc092..1588cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changes in 7.2.0 +# Changes in 7.1.8-beta.1 - Added: media type (content type) and file size validation for `IFormFile` uploads (Issue #216) - New File-level FluentValidation rules in `MicroElements.OpenApi.FluentValidation` (namespace `MicroElements.OpenApi.FluentValidation.FileUpload`): `.FileContentType(params string[])`, `.MaxFileSize(long)`, `.MinFileSize(long)`, `.FileSizeBetween(long, long)` on `IRuleBuilder`. They both enforce validation at runtime and surface metadata for OpenAPI generation - Root cause: rules on nested `IFormFile` members (`RuleFor(x => x.File.Length)` / `RuleFor(x => x.File.ContentType)`) are named `File.Length` / `File.ContentType` and never match the flat schema property `File`, so they were silently dropped; and `Must(...)` is opaque so allowed content types could not be reflected. Use the new File-level rules instead diff --git a/version.props b/version.props index 2d490db..4090c6c 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.2.0 - + 7.1.8 + beta.1