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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.<part>.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<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)
- **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)

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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).
- 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -77,6 +81,92 @@ public Task TransformAsync(
return Task.CompletedTask;
}

/// <summary>
/// Issue #216: writes <c>encoding.&lt;part&gt;.contentType</c> for IFormFile parts restricted via
/// <c>.FileContentType(...)</c>. 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 <c>$ref</c> (net10) —
/// only the property key is needed, never the resolved part schema.
/// </summary>
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<string, OpenApiEncoding>();
if (!mediaType.Encoding.TryGetValue(partKey, out var encoding) || encoding == null)
mediaType.Encoding[partKey] = encoding = new OpenApiEncoding();
encoding.ContentType = string.Join(", ", allowed);
}
}
}

/// <summary>
/// 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 <c>$ref</c> to a component whose <c>Target</c> is not yet resolved
/// when the operation transformer runs — so resolve that component from the document's registered schemas.
/// </summary>
private static IReadOnlyCollection<string> 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<string>();
}

private void ApplyRulesToParameters(OpenApiOperation operation, OpenApiOperationTransformerContext context)
{
var methodInfo = GetMethodInfo(context.Description);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,64 @@
// 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;

namespace MicroElements.AspNetCore.OpenApi.FluentValidation.Tests;

/// <summary>
/// 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 <c>encoding.contentType</c> on the multipart media type.
/// On net9 the multipart schema is inlined (description on the <c>file</c> property); on net10 the file part
/// is a <c>$ref</c> to a shared <c>IFormFile</c> component (description on that component).
/// Encoding.contentType is NOT emitted on this backend.
/// </summary>
public class Issue216SpikeTests : IClassFixture<AspNetCoreOpenApiTests.TestWebApplicationFactory>
{
private readonly AspNetCoreOpenApiTests.TestWebApplicationFactory _factory;

public Issue216SpikeTests(AspNetCoreOpenApiTests.TestWebApplicationFactory factory) => _factory = factory;

[Fact]
public async Task FileContentType_And_MaxFileSize_Are_Documented_For_Upload_Endpoint()
private async Task<JsonElement> 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");
}
}
2 changes: 1 addition & 1 deletion version.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>7.1.8</VersionPrefix>
<VersionSuffix>beta.1</VersionSuffix>
<VersionSuffix>beta.2</VersionSuffix>
</PropertyGroup>
</Project>
Loading