Skip to content

fix(#223): apply FluentValidation rules to every endpoint sharing a [FromQuery] DTO#224

Merged
avgalex merged 3 commits into
masterfrom
feature/issue-223-shared-dto
Jul 12, 2026
Merged

fix(#223): apply FluentValidation rules to every endpoint sharing a [FromQuery] DTO#224
avgalex merged 3 commits into
masterfrom
feature/issue-223-shared-dto

Conversation

@avgalex

@avgalex avgalex commented Jul 9, 2026

Copy link
Copy Markdown
Member

Fixes #223.

Problem

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. Reproduces under the default RemoveUnusedQuerySchemas = true. Reported and diagnosed by @jgarciadelanoceda. 🙏

Root cause

FluentValidationOperationFilter runs once per operation. The Issue #180 cleanup removes the temporary [FromQuery] container schema from SchemaRepository.Schemas, but Swashbuckle also keeps the type in its internal reserved-ids map (RegisterType/TryLookupByType), which the cleanup does not touch.

For the 2nd+ endpoint, GetSchemaForTypeGenerateSchema hits the reserved-but-removed type and returns a bare $ref (no Properties); the concrete schema is no longer in Schemas, so the if (schema.Properties.Count > 0) guard is skipped and no rules are applied.

That is also why RemoveUnusedQuerySchemas = false "fixes" it — the container schema stays in Schemas (at the cost of leaking unused query schemas into components).

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 the Issue #180 cleanup and all existing OperationFilter behavior intact:

It is ~10 lines in one method and changes no filter registration, so regression risk is low. (This realizes the intent of the "document filter" direction — keep the SchemaFilter, fix the shared-DTO bug — without ripping out the OperationFilter, which still owns the request-body/#216 handling that a pure replacement would have regressed.)

Tests

Adds SharedDtoAcrossEndpointsTest (net8.0/net9.0): drives two endpoints binding one [FromQuery] DTO through a single SchemaRepository — exactly how the real Swagger pipeline works — and asserts both parameters expose the constraints. Fails before the fix (secondName.MinLength null), passes after.

Full suite green on all targets: net8.0 118/118, net9.0 118/118, net10.0 86/86 (OPENAPI_V2 excludes the framework-typed integration tests, as with the other OperationFilter tests).

🤖 Generated with Claude Code

…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) <noreply@anthropic.com>
@avgalex

avgalex commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

@claude review this PR

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @avgalex's task in 5m 4s —— View job


Reviewing PR #224

Review

The root-cause analysis and fix are correct. I confirmed that Swashbuckle's reserved-id/reservation state lives on the SchemaRepository instance itself (not the generator or any static/global cache), so generating into a brand-new throwaway SchemaRepository (src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/SwashbuckleSchemaProvider.cs:78-86) genuinely sidesteps the reserved-but-removed state without touching the shared repository. Good, minimal, isolated fix.

I also traced how the recovered schema is used downstream in FluentValidationOperationFilter.ApplyRulesToParameters (FluentValidationOperationFilter.cs:147-249): the container schema (whether from the repo or the new throwaway fallback) is only ever used to read constraint values, which are then explicitly copied field-by-field onto operationParameter.Schema — the real document object. So it's safe that the recovered schema instance lives in a disposable repository; nothing depends on its object identity being shared with the document. The FluentValidationDocumentFilter experimental path is unaffected by #223 in the first place (it batches all types/parameters across the whole document before doing the Issue #180 cleanup once at the end), so it correctly wasn't touched.

Two things worth a look, neither of which I'd block on:

  1. ApplyRulesToRequestBody may not fully benefit from this fix (FluentValidationOperationFilter.cs:406-494). Unlike the parameters path, when schemaRefId != null it sets resolvedSchema = schemaProvider.GetSchemaForType(parameterType) (line 464) and then passes that same object directly into FluentValidationSchemaBuilder.ApplyRulesToSchema as the mutation target — there's no copy-back onto a separate "real" schema object. If a DTO is ever bound via [FromBody]/[FromForm] and shares a type with a [FromQuery] endpoint that triggers the Issue Validators for records used with [AsParameters] create unused component schemas #180 removal, the new fallback would recover a throwaway schema, but any rules applied to it would be silently lost (mutating a disposable object, never seen by the document). This isn't a regression — before this PR that path already skipped via the empty-Properties guard — but it means the fix's scope is (correctly, per the PR title) limited to the "container schema used only for reading constraints" pattern, and doesn't fully close the class of bug for body-bound shared DTOs. Worth a follow-up issue if that scenario is realistic for consumers.

  2. Minor perf: GetSchemaForType is called once per operation parameter (see the loop in ApplyRulesToParameters, plus once per ancestor segment in IsPropertyRequiredInType), not once per operation. Every time the new fallback triggers, it does a full _schemaGenerator.GenerateSchema into a fresh SchemaRepository — for a shared DTO with N query parameters, that's N redundant full regenerations per operation instead of one. A small per-SwashbuckleSchemaProvider-instance cache (it's already scoped to a single operation) would avoid the duplicate work. Low risk either way given typical DTO sizes.

Fix this →

Test coverage: SharedDtoAcrossEndpointsTest correctly drives two operations through one shared SchemaRepository/SchemaGenerator (matching the real Swagger pipeline) with RemoveUnusedQuerySchemas = true — this is the right way to reproduce the bug, and it's gated #if !OPENAPI_V2 consistent with the other OperationFilter integration tests. One gap: the #if OPENAPI_V2 branch of GetSchemaForType (lines 43-55, feeding into the same new fallback) has no test exercising the fallback under that compilation symbol — likely acceptable given the existing precedent of excluding framework-typed integration tests there, but flagging for awareness.

Note: I wasn't able to run dotnet build/dotnet test in this sandbox (commands beyond basic shell built-ins required approval that wasn't available non-interactively), so I relied on static tracing of the reserved-id/repository behavior rather than executing the test suite. The PR description's reported results (net8.0 118/118, net9.0 118/118, net10.0 86/86) should be verified by CI.

Verdict: Sound, well-scoped fix with a solid regression test. The two notes above are follow-up-worthy, not blockers.

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) <noreply@anthropic.com>
…hared-dto

# Conflicts:
#	CHANGELOG.md
#	version.props
@jgarciadelanoceda

Copy link
Copy Markdown

@avgalex I see you have doubts about implementing.
Have you thought about taking advantage of this domaindrivendev/Swashbuckle.AspNetCore#3708

If the type generates a schema then point the type in a list an do the call to ReplaceSchemaId for every member on the list at he end before removing it. You could replace it with Guid.NewGuid for example

@avgalex

avgalex commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

@jgarciadelanoceda Thanks — that's a good pointer. ReplaceSchemaId (added in Swashbuckle 10.1.0, domaindrivendev/Swashbuckle.AspNetCore#3708) would indeed let the Issue #180 cleanup heal the repository state instead of leaving the type reserved-but-removed: track the container types whose schemas we generated as a side effect, call ReplaceSchemaId(type, tempId) before removing, then remove the renamed entry. That clears the reservation, so the next endpoint sharing the DTO regenerates a full schema and the #223 desync never arises in the first place. It would also close the body-bound gap tracked in #226, and it removes a latent hazard: in the current reserved-but-removed state, a later [FromBody]/response usage of the same type would emit a $ref to a component that no longer exists.

The blocker for making it the fix: our net8.0/net9.0 targets pin Swashbuckle.AspNetCore.SwaggerGen 8.1.1, and ReplaceSchemaId only shipped in 10.1.0 (our net10.0 target is on 10.2.1, so it is available there). Bumping the minimum for the older TFMs would force consumers through the Microsoft.OpenApi 2.x breaking change, which we don't want to do.

So the plan is:

One caveat: GenerateSchema also creates components for nested complex types as a side effect, and those leave reservations behind too. ReplaceSchemaId needs the Type, which we only know for the root container, so the throwaway fallback stays as a safety net for nested types regardless.

@avgalex avgalex merged commit 55c62b0 into master Jul 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When one or more endpoints have the same Dto the firts one gets the FluentValidationRule and the next ones are not getting it

2 participants