From b1ceda60e18c079faaf9dbb7560cebe21b08f017 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sun, 21 Jun 2026 13:16:20 +0300 Subject: [PATCH 1/2] feat(aspnetcore-openapi): emit encoding.contentType for IFormFile parts (#216) The FluentValidationOperationTransformer now writes requestBody.content["multipart/form-data"].encoding..contentType for parts restricted via .FileContentType(...), so UIs like Scalar/Swagger UI show the accepted media types (not just the description). Part-name resolution handles both shapes: an inline multipart schema (net9, and some net10 endpoints) and a whole-body $ref to a component (net10). In the $ref case the reference Target is not resolved at operation-transform time, so the component is looked up in context.Document.Components.Schemas by ref id. Test: new Issue216SpikeTests.FileContentType_Emits_Encoding_ContentType verifies the encoding appears in /openapi/v1.json on net9 and net10. Release: 7.1.8-beta.2. Backend matrix is now content-type+size description AND encoding.contentType across all three backends. Full solution green: Swashbuckle 117/117/86, NSwag 3/3, AspNetCore.OpenApi 18/18. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +- README.md | 6 +- .../FluentValidationOperationTransformer.cs | 88 +++++++++++++++++++ .../Issue216SpikeTests.cs | 37 +++++--- version.props | 2 +- 5 files changed, 123 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1588cfc..bd1cf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ +# Changes in 7.1.8-beta.2 +- All of `7.1.8-beta.1` below, plus: **Microsoft.AspNetCore.OpenApi** now also emits `encoding.contentType` for the file part (Issue #216) — the `FluentValidationOperationTransformer` writes `requestBody.content["multipart/form-data"].encoding..contentType` so UIs like Scalar/Swagger UI can show the accepted media types, not just the description. Works on net9.0 (inline form schema) and net10.0 (resolves the whole-body `$ref` component to find the part name) + # 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) + - **Microsoft.AspNetCore.OpenApi**: the allowed types and size limits are appended to the file property `description`, and (since `7.1.8-beta.2`) the allowed types are also emitted as `encoding.contentType` on the multipart media type - 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) diff --git a/README.md b/README.md index 26233a8..dfb57f2 100644 --- a/README.md +++ b/README.md @@ -228,14 +228,14 @@ Backend support: |---|---|---| | 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) | +| Microsoft.AspNetCore.OpenApi | ✅ | ✅ (net9 + net10) | -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. +The issue scenario — making the generated OpenAPI document reflect the allowed content types and size limit — works on **all three** backends, both via the file part `description` and as the machine-readable `encoding.contentType`. 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). +- Microsoft.AspNetCore.OpenApi: on net10 the file part is emitted as a `$ref` to a shared `IFormFile` component, so the size/content-type **`description`** is shared across all `IFormFile` endpoints (differing per-endpoint rules would accumulate there) — but `encoding.contentType` is per-operation and unaffected. ## Extensibility diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs index e4f69b8..3781a1d 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs @@ -11,6 +11,7 @@ using MicroElements.OpenApi; using MicroElements.OpenApi.Core; using MicroElements.OpenApi.FluentValidation; +using MicroElements.OpenApi.FluentValidation.FileUpload; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.Logging; @@ -68,6 +69,9 @@ public Task TransformAsync( { ApplyRulesToParameters(operation, context); } + + // Issue #216: emit multipart/form-data encoding.contentType for IFormFile parts. + ApplyRulesToRequestBody(operation, context); } catch (Exception e) { @@ -77,6 +81,90 @@ public Task TransformAsync( return Task.CompletedTask; } + /// + /// Issue #216: writes encoding.<part>.contentType for IFormFile parts restricted via + /// .FileContentType(...). The part key is taken verbatim from the multipart schema and matched to the + /// rule name-insensitively, so it works whether the part schema is inline (net9) or a $ref (net10) — + /// only the property key is needed, never the resolved part schema. + /// + private void ApplyRulesToRequestBody(OpenApiOperation operation, OpenApiOperationTransformerContext context) + { +#if OPENAPI_V2 + var requestBody = operation.RequestBody as OpenApiRequestBody; +#else + var requestBody = operation.RequestBody; +#endif + if (requestBody?.Content == null) + return; + + if (!requestBody.Content.TryGetValue("multipart/form-data", out var mediaType) || mediaType == null) + return; + + var partKeys = GetFormPartKeys(mediaType.Schema, context); + if (partKeys.Count == 0) + return; + + var methodInfo = GetMethodInfo(context.Description); + if (methodInfo == null) + return; + + foreach (var parameter in 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 partKey in partKeys) + { + 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) }; + } + } + } + + /// + /// Resolves the multipart form part names. The form schema is inline on net9 (and for some net10 endpoints), + /// but on net10 the whole body is often a $ref to a component whose Target is not yet resolved + /// when the operation transformer runs — so resolve that component from the document's registered schemas. + /// + private static IReadOnlyCollection GetFormPartKeys( +#if OPENAPI_V2 + IOpenApiSchema? schema, +#else + OpenApiSchema? schema, +#endif + OpenApiOperationTransformerContext context) + { + if (schema?.Properties is { Count: > 0 }) + return schema.Properties.Keys.ToList(); + +#if OPENAPI_V2 + if (schema is OpenApiSchemaReference schemaRef + && schemaRef.Reference?.Id is { } refId + && context.Document?.Components?.Schemas is { } componentSchemas + && componentSchemas.TryGetValue(refId, out var component) + && component.Properties is { Count: > 0 }) + { + return component.Properties.Keys.ToList(); + } +#endif + + return Array.Empty(); + } + private void ApplyRulesToParameters(OpenApiOperation operation, OpenApiOperationTransformerContext context) { var methodInfo = GetMethodInfo(context.Description); diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs index cd75292..59df048 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs @@ -1,8 +1,8 @@ // Copyright (c) MicroElements. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Linq; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using Xunit; @@ -10,10 +10,10 @@ 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. +/// The allowed content types and size limit are documented on the file part description, and the allowed +/// content types are also emitted as a machine-readable encoding.contentType on the multipart media type. /// 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 { @@ -21,24 +21,41 @@ public class Issue216SpikeTests : IClassFixture _factory = factory; - [Fact] - public async Task FileContentType_And_MaxFileSize_Are_Documented_For_Upload_Endpoint() + private async Task GetMultipartMediaTypeAsync() { 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") + var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("paths").GetProperty("/api/upload") .GetProperty("post").GetProperty("requestBody") - .GetProperty("content").TryGetProperty("multipart/form-data", out _) - .Should().BeTrue(); + .GetProperty("content").GetProperty("multipart/form-data"); + } + + [Fact] + public async Task FileContentType_And_MaxFileSize_Are_Documented_For_Upload_Endpoint() + { + var client = _factory.CreateClient(); + var json = await (await client.GetAsync("/openapi/v1.json")).Content.ReadAsStringAsync(); // 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"); } + + [Fact] + public async Task FileContentType_Emits_Encoding_ContentType_For_Upload_Endpoint() + { + var multipart = await GetMultipartMediaTypeAsync(); + + multipart.TryGetProperty("encoding", out var encoding) + .Should().BeTrue("the multipart media type should carry an encoding object for the file part"); + + // One file part; its encoding.contentType lists the allowed media types (comma-joined, spec-style). + var fileEncoding = encoding.EnumerateObject().Select(property => property.Value).First(); + fileEncoding.GetProperty("contentType").GetString().Should().Be("image/jpeg, image/png"); + } } diff --git a/version.props b/version.props index 4090c6c..d57ff45 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ 7.1.8 - beta.1 + beta.2 From 858432389bcb6f5522661141e82c679eeced1e5a Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sun, 21 Jun 2026 13:33:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(#216):=20address=20PR=20#219=20review?= =?UTF-8?q?=20=E2=80=94=20encoding=20merge,=20test=20JsonDocument=20lifeti?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FluentValidationOperationTransformer: merge into an existing OpenApiEncoding (set only ContentType) instead of replacing the whole object, preserving any Headers/Style/Explode that an earlier stage may have set (matches the NSwag processor's pattern). - Issue216SpikeTests: dispose the JsonDocument (using) and Clone() the returned JsonElement so it no longer references the document's pooled buffer (latent lifetime bug); select the encoding entry by the "File" part name instead of .First() so the assertion is robust to ordering and self-documenting. Green: AspNetCore.OpenApi 18/18 (net9/net10). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FluentValidationOperationTransformer.cs | 4 +++- .../Issue216SpikeTests.cs | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs index 3781a1d..354e4e6 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs @@ -130,7 +130,9 @@ private void ApplyRulesToRequestBody(OpenApiOperation operation, OpenApiOperatio continue; mediaType.Encoding ??= new Dictionary(); - mediaType.Encoding[partKey] = new OpenApiEncoding { ContentType = string.Join(", ", allowed) }; + if (!mediaType.Encoding.TryGetValue(partKey, out var encoding) || encoding == null) + mediaType.Encoding[partKey] = encoding = new OpenApiEncoding(); + encoding.ContentType = string.Join(", ", allowed); } } } diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs index 59df048..fd2cf0e 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Issue216SpikeTests.cs @@ -28,10 +28,11 @@ private async Task GetMultipartMediaTypeAsync() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(json); + // Clone so the returned element does not depend on the disposed JsonDocument's pooled buffer. + using var doc = JsonDocument.Parse(json); return doc.RootElement.GetProperty("paths").GetProperty("/api/upload") .GetProperty("post").GetProperty("requestBody") - .GetProperty("content").GetProperty("multipart/form-data"); + .GetProperty("content").GetProperty("multipart/form-data").Clone(); } [Fact] @@ -54,8 +55,10 @@ public async Task FileContentType_Emits_Encoding_ContentType_For_Upload_Endpoint multipart.TryGetProperty("encoding", out var encoding) .Should().BeTrue("the multipart media type should carry an encoding object for the file part"); - // One file part; its encoding.contentType lists the allowed media types (comma-joined, spec-style). - var fileEncoding = encoding.EnumerateObject().Select(property => property.Value).First(); + // The "File" part's encoding.contentType lists the allowed media types (comma-joined, spec-style). + var fileEncoding = encoding.EnumerateObject() + .First(property => property.Name.Equals("File", StringComparison.OrdinalIgnoreCase)) + .Value; fileEncoding.GetProperty("contentType").GetString().Should().Be("image/jpeg, image/png"); } }