diff --git a/CHANGELOG.md b/CHANGELOG.md index 157e18c..1588cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 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 + - **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/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/README.md b/README.md index 1716b56..26233a8 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,61 @@ 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 | 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 | ✅ | ❌ 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 You can register FluentValidationRule in ServiceCollection. 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.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/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..1cbeef6 --- /dev/null +++ b/src/MicroElements.NSwag.FluentValidation/FluentValidationOperationProcessor.cs @@ -0,0 +1,118 @@ +// 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 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) + media.Encoding[part.Key] = encoding = new OpenApiEncoding(); + + 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/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadDescription.cs new file mode 100644 index 0000000..ea578bd --- /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..a297173 --- /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 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..ee9db10 --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidatorExtensions.cs @@ -0,0 +1,103 @@ +// 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) + { + 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 new file mode 100644 index 0000000..205e2f7 --- /dev/null +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/FileUpload/FileUploadValidators.cs @@ -0,0 +1,112 @@ +// 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; + + var tooSmall = MinSizeBytes is { } min && value.Length < min; + var tooLarge = MaxSizeBytes is { } max && value.Length > max; + if (!tooSmall && !tooLarge) + return true; + + // 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 false; + } + + /// + protected override string GetDefaultMessageTemplate(string errorCode) + { + 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/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..a6615af 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,53 @@ 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) + { + var partSchema = OpenApiSchemaCompatibility.GetProperty(resolvedSchema, partKey, schemaRepository); + if (partSchema == null || !OpenApiSchemaCompatibility.IsBinaryFormat(partSchema)) + continue; + + var allowed = contentTypeRules + .Where(rule => rule.MemberName.EqualsIgnoreAll(partKey)) + .Select(rule => rule.Meta.AllowedContentTypes) + .FirstOrDefault(); + 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.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 diff --git a/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs b/test/MicroElements.NSwag.FluentValidation.Tests/FluentValidationOperationProcessorTests.cs new file mode 100644 index 0000000..6eb5f64 --- /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 + + + + + + + + + + + + + + + + + + + diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs new file mode 100644 index 0000000..638fe40 --- /dev/null +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/IFormFileMediaTypeTests.cs @@ -0,0 +1,277 @@ +// 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(); + } + + [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"); + } + + [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( + 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 + } +} diff --git a/version.props b/version.props index f7ed6f5..4090c6c 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.1.7 - + 7.1.8 + beta.1