Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<T, IFormFile>`. 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.<part>.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<FluentValidationOperationProcessor>())`. 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`
Expand Down
15 changes: 15 additions & 0 deletions MicroElements.Swashbuckle.FluentValidation.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<UploadProductImageRequest>
{
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<FluentValidationOperationProcessor>())` (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.
Expand Down
7 changes: 6 additions & 1 deletion samples/SampleNSwagWebApi/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ public void ConfigureServices(IServiceCollection services)

services.AddOpenApiDocument((settings, serviceProvider) =>
{
var fluentValidationSchemaProcessor = serviceProvider.CreateScope().ServiceProvider.GetService<FluentValidationSchemaProcessor>();
var scopedProvider = serviceProvider.CreateScope().ServiceProvider;

// Add the fluent validations schema processor
var fluentValidationSchemaProcessor = scopedProvider.GetService<FluentValidationSchemaProcessor>();
settings.SchemaSettings.SchemaProcessors.Add(fluentValidationSchemaProcessor);

// Issue #216: add the operation processor that emits multipart/form-data file content types
var fluentValidationOperationProcessor = scopedProvider.GetService<FluentValidationOperationProcessor>();
settings.OperationProcessors.Add(fluentValidationOperationProcessor);
});

// Register FV validators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,6 +159,27 @@ public IEnumerable<IFluentValidationRule<OpenApiSchema>> 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 =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public static IServiceCollection AddFluentValidationRulesToSwagger(
// Add the FluentValidationSchemaProcessor as a scoped service
services.AddScoped<FluentValidationSchemaProcessor>();

// Issue #216: Add the FluentValidationOperationProcessor (emits multipart/form-data file content types).
services.AddScoped<FluentValidationOperationProcessor>();

// Adds default IValidatorRegistry
services.TryAdd(new ServiceDescriptor(typeof(IValidatorRegistry), typeof(ServiceProviderValidatorRegistry), registrationOptions.ServiceLifetime));

Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// NSwag <see cref="IOperationProcessor"/> that emits <c>multipart/form-data</c> encoding for
/// <c>IFormFile</c> parts restricted via <c>.FileContentType(...)</c>.
/// <para>
/// Note: NSwag's <c>OpenApiEncoding.EncodingType</c> serializes as <c>encodingType</c> rather than the
/// OpenAPI-spec <c>contentType</c> (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.
/// </para>
/// </summary>
public class FluentValidationOperationProcessor : IOperationProcessor
{
private const string MultipartContentType = "multipart/form-data";

private readonly ILogger _logger;
private readonly IValidatorRegistry? _validatorRegistry;
private readonly SchemaGenerationOptions _schemaGenerationOptions;

/// <summary>
/// Initializes a new instance of the <see cref="FluentValidationOperationProcessor"/> class.
/// </summary>
/// <param name="loggerFactory"><see cref="ILoggerFactory"/> for logging. Can be null.</param>
/// <param name="validatorRegistry">Gets validators for a particular type.</param>
/// <param name="schemaGenerationOptions">Schema generation options.</param>
public FluentValidationOperationProcessor(
ILoggerFactory? loggerFactory = null,
IValidatorRegistry? validatorRegistry = null,
IOptions<SchemaGenerationOptions>? schemaGenerationOptions = null)
{
_logger = loggerFactory?.CreateLogger(typeof(FluentValidationOperationProcessor)) ?? NullLogger.Instance;
_validatorRegistry = validatorRegistry;
_schemaGenerationOptions = schemaGenerationOptions?.Value ?? new SchemaGenerationOptions();
}

/// <inheritdoc />
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);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

<ItemGroup>
<PackageReference Include="NJsonSchema" Version="11.3.2" />
<!-- IOperationProcessor / OperationProcessorContext and the NSwag.Core OpenApi model (request body, media type, encoding). -->
<PackageReference Include="NSwag.Generation" Version="14.4.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
},
},
};
}

Expand Down
Loading
Loading