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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Changes in 7.1.11
- Fixed: a DTO shared between a flattened `[FromQuery]`/`[AsParameters]` binding and a request body (`[FromBody]`/`[FromForm]`) in the same document could lose its FluentValidation rules, and the emitted document could contain a `$ref` to a removed component (Issue #226, ADR-006). net10.0 target only
- Root cause: the Issue #180 cleanup left the container type "reserved-but-removed" in Swashbuckle's `SchemaRepository` (component removed, internal reserved-id kept). The 7.1.10 fix (#223) recovered the schema for *reading* constraint values, but a later `[FromBody]`/`[FromForm]` operation binding the same type made Swashbuckle emit a `$ref` to a component that no longer exists (confirmed empirically), and rules applied on the recovered throwaway instance never reached the document
- Fix (net10.0 / Swashbuckle 10.x): the Issue #180 cleanup now heals the repository state via `SchemaRepository.ReplaceSchemaId` (public API since Swashbuckle 10.1.0) before removing a side-effect schema — the reservation is cleared together with the component, so the next operation binding the same type regenerates a full component and the rules reach the real document object. `SwashbuckleSchemaProvider` tracks `Type → schemaId` for every `GetSchemaForType` call (including the Issue #209 ancestor walk) to make the healing possible
- net8.0/net9.0 keep the 7.1.10 behavior: they pin Swashbuckle 8.1.1 where `ReplaceSchemaId` does not exist, and bumping the minimum would force the Microsoft.OpenApi 2.x breaking change on consumers. The throwaway recovery from #223 remains as a safety net on all targets
- Design record: `docs/adr/ADR-006-shared-dto-state-healing-cleanup.md`. Thanks to @jgarciadelanoceda for suggesting the `ReplaceSchemaId` approach

# 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`
Expand Down
181 changes: 181 additions & 0 deletions docs/adr/ADR-006-shared-dto-state-healing-cleanup.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions docs/reviews/ADR-006-iteration-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ADR-006 Iteration Log

ADR: `docs/adr/ADR-006-shared-dto-state-healing-cleanup.md`
Driven by: `/adr-autonomous` (issue #226)

## Iteration 0 — Recon + assumptions (2026-07-12)

- Step −1 (Explore recon) findings that shaped the draft:
- `ApplyRulesToRequestBody` skips `application/json` entirely (content-type gate `FluentValidationOperationFilter.cs:416-422`) — JSON bodies are owned by the schema filter, so the #226 `[FromBody]` failure mode is a **dangling `$ref`** (component removed, reservation prevents regeneration), not a lost in-place mutation.
- `[FromForm]` path mutates `resolvedSchema` in place with no copy-back (`FluentValidationOperationFilter.cs:464,480-485`) — throwaway-recovered instance loses rules.
- `FluentValidationDocumentFilter` (Variant B) does not process request bodies at all today → cannot close #226 as-is.
- No mixed `[FromQuery]`+`[FromBody]` test exists; `SharedDtoAcrossEndpointsTest` is `#if !OPENAPI_V2` (no net10.0 shared-DTO coverage).
- Parity: NSwag / AspNetCore.OpenApi pipelines have no `SchemaRepository`/reserved-ids — Swashbuckle-package-local change.
- Repro seed: `samples/SampleWebApi/Controllers/Issue80.cs` (`BestShot` bound as both `[FromBody]` and `[FromQuery]`).
- Step 0 assumptions confirmed by user (2026-07-12): Swashbuckle package only; Variant C on net10.0/`OPENAPI_V2` only; **no reflection** on net8/net9; milestone **v7.1.11** (patch); out of scope: Variant B promotion, Swashbuckle minimum bump, NSwag/AspNetCore.OpenApi parity.

## Iteration 1 — Draft v1 (2026-07-12)

- Wrote ADR-006 v1: Context (two failure modes, dangling-`$ref` marked as hypothesis requiring Phase 1 repro), 4 options (A CHOSEN: `ReplaceSchemaId` state-healing on net10.0; B reflection REJECTED per user; C Variant B REJECTED for this ADR; D do-nothing REJECTED), Decision with cleanup mechanics sketch, 3-phase Implementation (repro tests → healing → matrix verification), Testing (why existing tests missed it), Consequences (TFM divergence, nested-type residual gap, semver patch).
- `/review-adr` iteration 1 result: **blocker 0, important 1, nit 2** (raw 4 findings from 3 validators; 1 duplicate deduped). Validator 1 (dotnet-architect) returned zero findings and independently corroborated `ReplaceSchemaId` presence by decompiling cached Swashbuckle DLLs (present in 10.1.5/10.2.1, absent in 10.0.0/8.1.1).

## Iteration 2 — Revise after review 1 (2026-07-12)

- Fixed **important** (Validator 2, rubric 5): Option C's first con overstated the gap — the document filter's end-of-document cleanup already prevents scenario 1 (JSON dangling `$ref`), and its type-level loop covers `[FromBody]` component schemas. Moved that fact to Option C **Pros**, narrowed the con to `operation.RequestBody`/`[FromForm]`/#216 encoding.
- Fixed **nit** (V2+V3, deduped): `Issue80.Validator : AbstractValidator<BestShot>` already exists in the sample — Phase 3 item 7 now says "use it as-is" instead of "add a validator". (Applied inline instead of deferring — trivial.)
- Fixed **nit** (V3): named the planned test artifacts — new `SharedDtoMixedBindingTests.cs`, #223 `OPENAPI_V2` port via extending `SharedDtoAcrossEndpointsTest.cs`. (Applied inline instead of deferring — trivial.)
- `/review-adr` iteration 2 result: **blocker 0, important 0, nit 3** → decision gate PASSED. Validator 2 re-verified the corrected Option C wording against `FluentValidationDocumentFilter.cs` (type selection 92-96/105-111, rules loop 164-201, snapshot 85-87, cleanup 253-265) — holds.

## Iteration 2 findings (all nit, applied inline — trivial and they sharpen implementation guidance)

- **[V1, rubric 1]** Option C Pros cited lines 92-111 for "applies rules", but that range only collects types; rule application is at 164-201. → Fixed: both ranges cited with correct verbs.
- **[V2, rubric 5]** Tracking scope was ambiguous: the #209 ancestor walk (`IsPropertyRequiredInType`, `FluentValidationOperationFilter.cs:358`) also calls `GetSchemaForType` with a fully known `Type`, so tracking only the parameters-loop call site would leave healable reservations stale. → Fixed: tracking moved into `SwashbuckleSchemaProvider` (covers every call site); Decision sketch, Phase 2 steps 4-5, Option A cons, and the Consequences-Negative bullet narrowed to "types that never pass through library code".
- **[V3, rubric 7]** "Variant C" used in Phase 1 item 3 but never defined in the ADR (its own Option C is the rejected document filter). → Fixed: replaced with "the chosen state-healing cleanup (Option A)".

## Exit (2026-07-12)

- Iterations used: **2 / 5**. Status: **PASS** (blocker 0, important 0 at iteration 2; the 3 iteration-2 nits were applied inline after the gate).
- Deferred: none.
- Next step: `/adr-implement` for `docs/adr/ADR-006-shared-dto-state-healing-cleanup.md` (Phase 1 repro tests first — they confirm or refute the marked dangling-`$ref` hypothesis).
52 changes: 52 additions & 0 deletions docs/reviews/ADR-006-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR-006 Review (2026-07-12)

**Final verdict (iteration 2): Approve** — blocker 0, important 0; 3 nits found in iteration 2 were applied inline (see `ADR-006-iteration-log.md`).

---

## Iteration 2 — re-validation after fixes

- Validator 1 (dotnet-architect): 1 nit — Option C Pros cited lines 92-111 for rule application (collection only; rules applied at 164-201). Fixed.
- Validator 2 (domain & parity): 1 nit — tracking scope ambiguous vs the #209 ancestor walk (`IsPropertyRequiredInType:358` also calls `GetSchemaForType` with a known `Type`). Fixed: tracking moved into `SwashbuckleSchemaProvider`, covering every call site. Re-verified the corrected Option C wording against code — holds.
- Validator 3 (structure): 1 nit — "Variant C" undefined inside the ADR. Fixed: replaced with in-ADR terminology (Option A).

---

# Iteration 1 (initial review)

ADR: `docs/adr/ADR-006-shared-dto-state-healing-cleanup.md`
Validators: 1 — `dotnet-artisan:dotnet-architect` (architecture/versions/semver), 2 — general-purpose (domain & parity), 3 — general-purpose (structure & completeness).

## Rubric summary

| # | Rubric item | Verdict |
|---|-------------|---------|
| 1 | Architecture & trade-offs (≥2 options, CHOSEN, layering) | ✅ 4 options, layering respected, core untouched |
| 2 | Generator parity | ✅ Swashbuckle-only justified; NSwag/AspNetCore.OpenApi verified to have no SchemaRepository mechanic |
| 3 | TFM/version matrix | ✅ All claims verified against code; `ReplaceSchemaId` corroborated by DLL decompilation (10.1.5/10.2.1 have it, 10.0.0/8.1.1 do not); the one hypothesis (dangling `$ref`) properly marked `requires investigation` |
| 4 | Public API & semver | ✅ No API change, patch v7.1.11 consistent |
| 5 | OpenAPI semantics | ⚠️ 1 important: Option C con overstated (document filter does cover JSON `[FromBody]` via type-level loop; scenario 1 doesn't arise under it) — **fixed in iteration 2** |
| 6 | Testing | ⚠️ 2 nits: stale "add a validator" premise (Issue80.Validator exists); planned tests not named — **both fixed in iteration 2** |
| 7 | Structure & format | ✅ English, header block, ADR-002/003 section style, REJECTED markers |

## 🔴 Blocker (0)


## 🟡 Important (1)

1. **[Validator 2, rubric 5, Section 2 Option C]** "The document filter does not process request bodies at all — would not fix #226 without substantial new code" overstates: `FluentValidationDocumentFilter`'s type-level loop (lines 92-111) covers `[FromBody]` component schemas and its end-of-document cleanup (253-265) prevents scenario 1; only scenario 2 (`[FromForm]`/encoding) needs substantial new code. → **Fixed**: fact moved to Option C Pros, con narrowed to `operation.RequestBody`/`[FromForm]`/#216.

## 🟢 Nit (2)

1. **[V2+V3 dedup, Phase 3 item 7]** `Issue80.Validator : AbstractValidator<BestShot>` (MinimumLength(5) on Link) already exists in the sample; "add a BestShot validator" was a stale premise. → **Fixed** ("use it as-is").
2. **[V3, Phase 1 / Testing]** Planned tests had no file/class names, unlike ADR-002/003 precedent. → **Fixed**: `SharedDtoMixedBindingTests.cs` (new), #223 `OPENAPI_V2` port via `SharedDtoAcrossEndpointsTest.cs`.

## Recommendation

**Approve with fixes** (fixes applied in iteration 2; re-validation results — see the iteration log).

## Validator statistics

- Raw findings: V1 = 0, V2 = 2 (1 important, 1 nit), V3 = 2 (2 nits) → total 4.
- After dedup (`rubric_item`+`location`): 3 (1 important, 2 nits) — the Issue80-validator nit was reported by both V2 and V3.
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,24 @@ private void ApplyRulesToParameters(OpenApiOperation operation, OperationFilterC
{
if (!existingSchemaIds.Contains(schemaId))
{
#if OPENAPI_V2
// Issue #226: clear Swashbuckle's internal reserved-id together with the removal, so the
// repository stays consistent and a later operation binding the same type (query, body or
// response) regenerates a full component instead of emitting a $ref to a removed one.
// ReplaceSchemaId (Swashbuckle 10.1.0+) is the only public API that reaches the reserved-id
// map and it must run BEFORE the removal; the temp GUID key cannot collide with a real id.
var trackedType = schemaProvider.RequestedSchemaIds
.FirstOrDefault(pair => pair.Value == schemaId).Key;
if (trackedType != null)
{
var tempSchemaId = "__fv_removed_" + Guid.NewGuid().ToString("N");
if (context.SchemaRepository.ReplaceSchemaId(trackedType, tempSchemaId))
{
context.SchemaRepository.Schemas.Remove(tempSchemaId);
continue;
}
}
#endif
context.SchemaRepository.Schemas.Remove(schemaId);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
#if OPENAPI_V2
using System.Collections.Generic;
#endif
using MicroElements.OpenApi.FluentValidation;
#if !OPENAPI_V2
using Microsoft.OpenApi.Models;
Expand All @@ -19,6 +22,18 @@ public class SwashbuckleSchemaProvider : ISchemaProvider<OpenApiSchema>
private readonly ISchemaGenerator _schemaGenerator;
private readonly Func<Type, string> _schemaIdSelector;

#if OPENAPI_V2
private readonly Dictionary<Type, string> _requestedSchemaIds = new Dictionary<Type, string>();

/// <summary>
/// Gets the types passed to <see cref="GetSchemaForType"/> mapped to their schema ids.
/// The Issue #180 cleanup uses this map to heal Swashbuckle's internal reserved-id state via
/// <c>SchemaRepository.ReplaceSchemaId</c> (Swashbuckle 10.1.0+) before removing a schema (Issue #226).
/// Covers every call site: the parameters loop and the Issue #209 ancestor-requiredness walk.
/// </summary>
internal IReadOnlyDictionary<Type, string> RequestedSchemaIds => _requestedSchemaIds;
#endif

/// <summary>
/// Initializes a new instance of the <see cref="SwashbuckleSchemaProvider"/> class.
/// </summary>
Expand All @@ -41,6 +56,8 @@ public OpenApiSchema GetSchemaForType(Type type)
var schemaId = _schemaIdSelector(type);

#if OPENAPI_V2
_requestedSchemaIds[type] = schemaId;

if (!_schemaRepository.Schemas.TryGetValue(schemaId, out var schemaInterface))
{
schemaInterface = _schemaGenerator.GenerateSchema(type, _schemaRepository);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,87 @@ private static OpenApiSchema ApplyToEndpoint(
return nameParamSchema;
}
#endif

// OPENAPI_V2 (net10.0) port of the same scenario — ADR-006 Phase 1: the net10.0 target where the
// state-healing cleanup (Issue #226) lives previously had no shared-DTO coverage at all.
#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>(schemaGenerationOptions));

var operationFilter = new FluentValidationOperationFilter(
validatorRegistry: validatorRegistry,
schemaGenerationOptions: new OptionsWrapper<SchemaGenerationOptions>(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 — every endpoint binding the shared HelloRequest must expose the rules (Issue #223).
firstName.MinLength.Should().Be(1, because: "the first endpoint has always worked");
firstName.MaxLength.Should().Be(10, because: "the first endpoint has always worked");
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)");
}

/// <summary>
/// Runs the <see cref="FluentValidationOperationFilter"/> for one GET endpoint that binds
/// <see cref="HelloRequest"/> via [FromQuery], and returns the resulting "Name" parameter schema.
/// </summary>
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 = JsonSchemaType.String };
var operation = new OpenApiOperation
{
Parameters = new List<IOpenApiParameter>
{
new OpenApiParameter { Name = "Name", In = ParameterLocation.Query, Schema = nameParamSchema },
},
};

var context = new OperationFilterContext(
apiDescription,
schemaGenerator,
schemaRepository,
new OpenApiDocument(),
typeof(SharedDtoAcrossEndpointsTest).GetMethod(nameof(OperationFilter_Should_Apply_Rules_To_Every_Endpoint_Sharing_The_Same_FromQuery_Dto))!);

operationFilter.Apply(operation, context);

return nameParamSchema;
}
#endif
}
}
Loading
Loading