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: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.9
- Fixed: numeric bounds from `short`/`byte`/`ushort`/`uint`/`ulong`/`sbyte`-typed rules were silently dropped (Issue #222)
- `IsNumeric` in the shared core (`MicroElements.OpenApi.FluentValidation`) only recognized `int`/`long`/`float`/`double`/`decimal`/`BigInteger`, so a `Between`/`Comparison` rule whose bound was a small integer type (e.g. `InclusiveBetween((short)1, (short)99)`) matched but produced no `minimum`/`maximum` β€” even though the generator emits `"type": "integer"` for the property. This could not be worked around at the validator definition site because those rule overloads only accept bounds of the property's own type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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 <see cref="SchemaRepository.Schemas"/>
/// but NOT from Swashbuckle's internal reserved-ids map. Because <see cref="FluentValidationOperationFilter"/>
/// 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
/// </summary>
public class SharedDtoAcrossEndpointsTest : UnitTestBase
{
public class HelloRequest
{
public string? Name { get; set; }
}

public class HelloRequestValidator : AbstractValidator<HelloRequest>
{
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>(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 β€” 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)");
}

/// <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 = "string" };
var operation = new OpenApiOperation
{
Parameters = new List<OpenApiParameter>
{
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
}
}
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.9</VersionPrefix>
<VersionPrefix>7.1.10</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
Loading