From 362bebcc08128d614906a99bcc227647637af936 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Thu, 9 Jul 2026 09:28:16 +0300 Subject: [PATCH 1/2] fix(#223): apply FluentValidation rules to every endpoint sharing a [FromQuery] DTO When the same [FromQuery]/[AsParameters] DTO is bound by more than one endpoint, only the first operation received its FluentValidation rules; the rest lost them (under the default RemoveUnusedQuerySchemas = true). Root cause: FluentValidationOperationFilter runs once per operation. The Issue #180 cleanup removes the temporary container schema from SchemaRepository.Schemas, but Swashbuckle keeps the type in its internal reserved-ids map, which the cleanup does not touch. For the 2nd+ endpoint, GetSchemaForType -> GenerateSchema then returns a bare $ref (no Properties) and the schema is no longer in Schemas, so the "has properties" guard is skipped and no rules are applied. Fix: in SwashbuckleSchemaProvider.GetSchemaForType, when the returned schema has no properties and the id is absent from the shared repository (the reserved-but-removed state), recover the concrete schema by generating it into a throwaway SchemaRepository. This is fully isolated - it never mutates the shared repository or its reserved-id state - so it keeps both Issue #180 cleanup and all existing OperationFilter behavior (required marking, nested [FromQuery], [FromForm]/[FromBody], Issue #216 encoding) intact. Adds a regression test (net8.0/net9.0) that drives two endpoints sharing one [FromQuery] DTO through a single SchemaRepository, as in the real Swagger pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Swashbuckle/SwashbuckleSchemaProvider.cs | 19 +++ .../SharedDtoAcrossEndpointsTest.cs | 132 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 test/MicroElements.Swashbuckle.FluentValidation.Tests/SharedDtoAcrossEndpointsTest.cs diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/SwashbuckleSchemaProvider.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/SwashbuckleSchemaProvider.cs index fcc6650..4bf3b65 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/SwashbuckleSchemaProvider.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/SwashbuckleSchemaProvider.cs @@ -66,6 +66,25 @@ public OpenApiSchema GetSchemaForType(Type type) } #endif + // Issue #223: when the same [FromQuery] DTO is shared by several endpoints, the per-operation + // Issue #180 cleanup removes the container schema from SchemaRepository.Schemas, but Swashbuckle + // still keeps the type in its internal reserved-ids map. For the 2nd+ endpoint, GenerateSchema then + // returns a bare $ref (no Properties) and the schema is no longer in Schemas, so the rules would be + // skipped. Recover the concrete schema by generating it into a throwaway repository — this is fully + // isolated: it never touches the shared repository or its reserved-id state. + if ((schema.Properties == null || schema.Properties.Count == 0) && + !_schemaRepository.Schemas.ContainsKey(schemaId)) + { + var throwawayRepository = new SchemaRepository(); + _schemaGenerator.GenerateSchema(type, throwawayRepository); + if (throwawayRepository.Schemas.TryGetValue(schemaId, out var concrete) + && concrete is OpenApiSchema concreteSchema + && concreteSchema.Properties is { Count: > 0 }) + { + schema = concreteSchema; + } + } + return schema; } diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SharedDtoAcrossEndpointsTest.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SharedDtoAcrossEndpointsTest.cs new file mode 100644 index 0000000..938b557 --- /dev/null +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SharedDtoAcrossEndpointsTest.cs @@ -0,0 +1,132 @@ +// 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.Swashbuckle.FluentValidation.Generation; +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 #223: when the same [FromQuery] DTO is bound by more than one endpoint, only the FIRST + /// operation receives the FluentValidation rules; the rest lose them. + /// + /// Root cause: the Issue #180 cleanup removes the container schema from + /// but NOT from Swashbuckle's internal reserved-ids map. Because + /// runs once per operation, the second operation's GetSchemaForType hits the reserved-but-removed type and + /// gets back a bare $ref schema (no Properties), so the rule-application guard is skipped. + /// + /// Repro requires RemoveUnusedQuerySchemas = true (the default) AND a SchemaRepository shared across + /// operations — exactly how the real Swagger pipeline works. + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/223 + /// + public class SharedDtoAcrossEndpointsTest : UnitTestBase + { + public class HelloRequest + { + public string? Name { get; set; } + } + + public class HelloRequestValidator : AbstractValidator + { + public HelloRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(10); + } + } + +// OperationFilter integration tests require framework-specific OpenApi types (excluded on net10.0/OPENAPI_V2). +#if !OPENAPI_V2 + [Fact] + public void OperationFilter_Should_Apply_Rules_To_Every_Endpoint_Sharing_The_Same_FromQuery_Dto() + { + // Arrange — a SINGLE SchemaRepository + SchemaGenerator shared across both operations, + // exactly like the real Swagger pipeline where SchemaRepository persists for the whole document. + var schemaGeneratorOptions = new SchemaGeneratorOptions(); + var schemaRepository = new SchemaRepository(); + var schemaGenerator = SchemaGenerator(new HelloRequestValidator()); + + var schemaGenerationOptions = new SchemaGenerationOptions + { + NameResolver = new SystemTextJsonNameResolver(), + SchemaIdSelector = schemaGeneratorOptions.SchemaIdSelector, + RemoveUnusedQuerySchemas = true, // the default; this is the flag that triggers the bug + }; + + var validatorRegistry = new ValidatorRegistry( + new IValidator[] { new HelloRequestValidator() }, + new OptionsWrapper(schemaGenerationOptions)); + + var operationFilter = new FluentValidationOperationFilter( + validatorRegistry: validatorRegistry, + schemaGenerationOptions: new OptionsWrapper(schemaGenerationOptions)); + + // Act — two endpoints ("/api/hello" and "/api/hello2") binding the same HelloRequest via [FromQuery]. + var firstName = ApplyToEndpoint(operationFilter, schemaGenerator, schemaRepository); + var secondName = ApplyToEndpoint(operationFilter, schemaGenerator, schemaRepository); + + // Assert — the FIRST endpoint gets the rules today... + firstName.MinLength.Should().Be(1, because: "the first endpoint has always worked"); + firstName.MaxLength.Should().Be(10, because: "the first endpoint has always worked"); + + // ...and the SECOND endpoint MUST get them too (currently FAILS — Issue #223). + secondName.MinLength.Should().Be(1, + because: "every endpoint binding the shared HelloRequest must expose NotEmpty (Issue #223)"); + secondName.MaxLength.Should().Be(10, + because: "every endpoint binding the shared HelloRequest must expose MaximumLength(10) (Issue #223)"); + } + + /// + /// Runs the for one GET endpoint that binds + /// via [FromQuery], and returns the resulting "Name" parameter schema. + /// + private static OpenApiSchema ApplyToEndpoint( + FluentValidationOperationFilter operationFilter, + SchemaGenerator schemaGenerator, + SchemaRepository schemaRepository) + { + var metadataProvider = new EmptyModelMetadataProvider(); + var nameMetadata = metadataProvider.GetMetadataForProperty(typeof(HelloRequest), nameof(HelloRequest.Name)); + + var apiDescription = new ApiDescription(); + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription + { + Name = "Name", + ModelMetadata = nameMetadata, + Source = BindingSource.Query, + }); + + var nameParamSchema = new OpenApiSchema { Type = "string" }; + var operation = new OpenApiOperation + { + Parameters = new List + { + new OpenApiParameter { Name = "Name", In = ParameterLocation.Query, Schema = nameParamSchema }, + }, + }; + + var context = new OperationFilterContext( + apiDescription, + schemaGenerator, + schemaRepository, + typeof(SharedDtoAcrossEndpointsTest).GetMethod(nameof(OperationFilter_Should_Apply_Rules_To_Every_Endpoint_Sharing_The_Same_FromQuery_Dto))!); + + operationFilter.Apply(operation, context); + + return nameParamSchema; + } +#endif + } +} From 5f9c25cffc881216acfc85144eb2433cae5a93d5 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Thu, 9 Jul 2026 10:08:42 +0300 Subject: [PATCH 2/2] chore(release): bump version to 7.1.10 for Issue #223 fix Distinct patch from PR #225 (Issue #222 -> 7.1.9) so both fixes can ship as independent NuGet packages regardless of merge order. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++++ version.props | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f735b2..10f03da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Changes in 7.1.10 +- Fixed: the same `[FromQuery]`/`[AsParameters]` DTO shared by more than one endpoint lost its FluentValidation rules on every endpoint after the first (Issue #223) + - `FluentValidationOperationFilter` runs once per operation. The Issue #180 cleanup removes the temporary container schema from `SchemaRepository.Schemas`, but Swashbuckle keeps the type in its internal reserved-ids map, which the cleanup does not touch. For the 2nd+ endpoint, `GetSchemaForType` → `GenerateSchema` then returns a bare `$ref` (no `Properties`) and the schema is no longer in `Schemas`, so the "has properties" guard was skipped and no rules were applied. Only reproduces with the default `RemoveUnusedQuerySchemas = true` + - Fix: in `SwashbuckleSchemaProvider.GetSchemaForType`, when the returned schema has no properties and the id is absent from the shared repository (the reserved-but-removed state), the concrete schema is recovered by generating it into a throwaway `SchemaRepository`. Fully isolated — it never mutates the shared repository or its reserved-id state — so the Issue #180 cleanup and all other `OperationFilter` behavior (required marking #209, nested `[FromQuery]` #211/#213, request bodies, #216) are preserved + # Changes in 7.1.8 - Security (Issue #220): closed a transitive high-severity advisory in the published package. `Swashbuckle.AspNetCore.SwaggerGen` on the net10.0 target was bumped `10.0.0` → `10.2.1`, which resolves `Microsoft.OpenApi` to the patched `2.7.5` (was `2.3.0`). This clears **GHSA-v5pm-xwqc-g5wc / CVE-2026-49451** (CWE-674 uncontrolled recursion — a circular `$ref` schema could stack-overflow the OpenAPI reader). The net8.0/net9.0 targets use Swashbuckle 8.1.1 → Microsoft.OpenApi v1 and were never in the advisory range - Media type & file size validation for `IFormFile` uploads (Issue #216): stable rollup of everything in `7.1.8-beta.1` and `7.1.8-beta.2` below (new File-level rules `.FileContentType()`, `.MaxFileSize()`, `.MinFileSize()`, `.FileSizeBetween()`; Swashbuckle / NSwag / Microsoft.AspNetCore.OpenApi emit multipart `encoding.contentType` and description annotations) diff --git a/version.props b/version.props index 554c461..ac108b0 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.1.8 + 7.1.10