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..354e4e6 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,92 @@ 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(); + if (!mediaType.Encoding.TryGetValue(partKey, out var encoding) || encoding == null) + mediaType.Encoding[partKey] = encoding = new OpenApiEncoding(); + encoding.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..fd2cf0e 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,44 @@ 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. + // Clone so the returned element does not depend on the disposed JsonDocument's pooled buffer. using var doc = JsonDocument.Parse(json); - doc.RootElement.GetProperty("paths").GetProperty("/api/upload") + 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").Clone(); + } + + [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"); + + // 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"); + } } 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