Skip to content

Commit 8358cd9

Browse files
authored
Merge pull request #201 from micro-elements/feature/issue-200-aspnetcore-operation-transformer
Add operation transformer for query parameter validation (Issue #200)
2 parents db4beb7 + 0942041 commit 8358cd9

9 files changed

Lines changed: 668 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
# Changes in 7.1.4-beta
2+
- Added: `FluentValidationOperationTransformer` (`IOpenApiOperationTransformer`) for `MicroElements.AspNetCore.OpenApi.FluentValidation` (Issue #200)
3+
- Query parameters with `[AsParameters]` now receive validation constraints (min/max, required, pattern, etc.)
4+
- Supports container type resolution with fallback via reflection for `[AsParameters]`
5+
- Copies validation constraints from schema properties to parameter schemas
6+
- Registered automatically via `AddFluentValidationRules()`
7+
- Fixed: Nested DTOs in request body not receiving validation constraints (Issue #200)
8+
- `FluentValidationSchemaTransformer` skipped all property-level schemas, but for nested object types this was the only transformer call
9+
- Now processes property-level schemas for complex types using the property type's validator
10+
111
# Changes in 7.1.3
212
- Fixed: `$ref` replaced with inline schema copy when using `SetValidator` with nested object types (Issue #198)
313
- `ResolveRefProperty` (introduced in 7.1.2 for BigInteger isolation) replaced all `$ref` properties with copies, destroying reference structure in the OpenAPI document

src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public static class OpenApiOptionsExtensions
1919
public static OpenApiOptions AddFluentValidationRules(this OpenApiOptions options)
2020
{
2121
options.AddSchemaTransformer<FluentValidationSchemaTransformer>();
22+
options.AddOperationTransformer<FluentValidationOperationTransformer>();
2223
return options;
2324
}
2425
}

src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public static IServiceCollection AddFluentValidationRulesToOpenApi(
3636
// Transient (not Scoped) because .NET 10 build-time document generation
3737
// runs without an HTTP scope, causing scoped resolution to fail.
3838
services.TryAddTransient<FluentValidationSchemaTransformer>();
39+
services.TryAddTransient<FluentValidationOperationTransformer>();
3940

4041
// Register JsonSerializerOptions (reference to Microsoft.AspNetCore.Mvc.JsonOptions.Value)
4142
services.TryAddTransient<AspNetJsonSerializerOptions>(provider => new AspNetJsonSerializerOptions(provider.GetJsonSerializerOptionsOrDefault()));
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
// Copyright (c) MicroElements. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Reflection;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using FluentValidation;
11+
using MicroElements.OpenApi;
12+
using MicroElements.OpenApi.Core;
13+
using MicroElements.OpenApi.FluentValidation;
14+
using Microsoft.AspNetCore.Http;
15+
using Microsoft.AspNetCore.OpenApi;
16+
using Microsoft.Extensions.Logging;
17+
using Microsoft.Extensions.Logging.Abstractions;
18+
using Microsoft.Extensions.Options;
19+
#if OPENAPI_V2
20+
using Microsoft.OpenApi;
21+
#else
22+
using Microsoft.OpenApi.Models;
23+
#endif
24+
25+
namespace MicroElements.AspNetCore.OpenApi.FluentValidation
26+
{
27+
/// <summary>
28+
/// <see cref="IOpenApiOperationTransformer"/> that applies FluentValidation rules
29+
/// to query, route, and header parameters generated by Microsoft.AspNetCore.OpenApi.
30+
/// Issue #200: Query parameters with [AsParameters] were not getting validation constraints.
31+
/// </summary>
32+
public class FluentValidationOperationTransformer : IOpenApiOperationTransformer
33+
{
34+
private readonly ILogger _logger;
35+
private readonly IValidatorRegistry _validatorRegistry;
36+
private readonly IReadOnlyList<IFluentValidationRule<OpenApiSchema>> _rules;
37+
private readonly SchemaGenerationOptions _schemaGenerationOptions;
38+
39+
/// <summary>
40+
/// Initializes a new instance of the <see cref="FluentValidationOperationTransformer"/> class.
41+
/// </summary>
42+
public FluentValidationOperationTransformer(
43+
ILoggerFactory? loggerFactory = null,
44+
IValidatorRegistry? validatorRegistry = null,
45+
IFluentValidationRuleProvider<OpenApiSchema>? fluentValidationRuleProvider = null,
46+
IEnumerable<FluentValidationRule>? rules = null,
47+
IOptions<SchemaGenerationOptions>? schemaGenerationOptions = null)
48+
{
49+
_logger = loggerFactory?.CreateLogger(typeof(FluentValidationOperationTransformer)) ?? NullLogger.Instance;
50+
_validatorRegistry = validatorRegistry ?? throw new ArgumentNullException(nameof(validatorRegistry));
51+
52+
fluentValidationRuleProvider ??= new DefaultFluentValidationRuleProvider(schemaGenerationOptions);
53+
_rules = fluentValidationRuleProvider.GetRules().ToArray().OverrideRules(rules);
54+
_schemaGenerationOptions = schemaGenerationOptions?.Value ?? new SchemaGenerationOptions();
55+
56+
_logger.LogDebug("FluentValidationOperationTransformer Created");
57+
}
58+
59+
/// <inheritdoc />
60+
public Task TransformAsync(
61+
OpenApiOperation operation,
62+
OpenApiOperationTransformerContext context,
63+
CancellationToken cancellationToken)
64+
{
65+
try
66+
{
67+
if (operation.Parameters != null && operation.Parameters.Count > 0)
68+
{
69+
ApplyRulesToParameters(operation, context);
70+
}
71+
}
72+
catch (Exception e)
73+
{
74+
_logger.LogWarning(0, e, "Error applying FluentValidation rules to operation parameters");
75+
}
76+
77+
return Task.CompletedTask;
78+
}
79+
80+
private void ApplyRulesToParameters(OpenApiOperation operation, OpenApiOperationTransformerContext context)
81+
{
82+
// Group parameters by container type to avoid redundant validator lookups and schema builds.
83+
var parameterGroups = new Dictionary<Type, (IValidator Validator, OpenApiSchema Schema)>();
84+
85+
foreach (var operationParameter in operation.Parameters)
86+
{
87+
var apiParameterDescription = context.Description.ParameterDescriptions
88+
.FirstOrDefault(d => d.Name.Equals(operationParameter.Name, StringComparison.InvariantCultureIgnoreCase));
89+
90+
var modelMetadata = apiParameterDescription?.ModelMetadata;
91+
if (modelMetadata == null)
92+
continue;
93+
94+
var parameterType = modelMetadata.ContainerType;
95+
96+
// Fallback: resolve container type from [AsParameters] attribute
97+
if (parameterType == null)
98+
{
99+
var methodInfo = GetMethodInfo(context.Description);
100+
parameterType = ResolveContainerType(operationParameter.Name, methodInfo);
101+
}
102+
103+
if (parameterType == null)
104+
continue;
105+
106+
// Reuse validator and schema for the same container type
107+
if (!parameterGroups.TryGetValue(parameterType, out var cached))
108+
{
109+
var validator = _validatorRegistry.GetValidator(parameterType);
110+
if (validator == null)
111+
continue;
112+
113+
var schema = BuildSchemaForType(parameterType);
114+
if (schema.Properties == null || schema.Properties.Count == 0)
115+
continue;
116+
117+
cached = (validator, schema);
118+
parameterGroups[parameterType] = cached;
119+
}
120+
121+
ApplyRulesToParameter(operationParameter, parameterType, cached.Validator, cached.Schema);
122+
}
123+
}
124+
125+
private void ApplyRulesToParameter(
126+
#if OPENAPI_V2
127+
IOpenApiParameter operationParameter,
128+
#else
129+
OpenApiParameter operationParameter,
130+
#endif
131+
Type parameterType,
132+
IValidator validator,
133+
OpenApiSchema schema)
134+
{
135+
var schemaPropertyName = operationParameter.Name;
136+
137+
// For nested [FromQuery] parameters (e.g., "operation.op"), use only the leaf name
138+
var dotIndex = schemaPropertyName.LastIndexOf('.');
139+
if (dotIndex >= 0)
140+
schemaPropertyName = schemaPropertyName.Substring(dotIndex + 1);
141+
142+
// Find matching property in schema
143+
var apiProperty = OpenApiSchemaCompatibility.GetProperties(schema)
144+
.FirstOrDefault(property => property.Key.EqualsIgnoreAll(schemaPropertyName));
145+
if (apiProperty.Key != null)
146+
{
147+
schemaPropertyName = apiProperty.Key;
148+
}
149+
else
150+
{
151+
var propertyInfo = parameterType.GetProperty(schemaPropertyName);
152+
if (propertyInfo != null && _schemaGenerationOptions.NameResolver != null)
153+
{
154+
schemaPropertyName = _schemaGenerationOptions.NameResolver.GetPropertyName(propertyInfo);
155+
}
156+
}
157+
158+
var schemaProvider = new AspNetCoreSchemaProvider(null, _logger);
159+
var schemaContext = new AspNetCoreSchemaGenerationContext(
160+
schema: schema,
161+
schemaType: parameterType,
162+
rules: _rules,
163+
schemaGenerationOptions: _schemaGenerationOptions,
164+
schemaProvider: schemaProvider);
165+
166+
FluentValidationSchemaBuilder.ApplyRulesToSchema(
167+
schemaType: parameterType,
168+
schemaPropertyNames: new[] { schemaPropertyName },
169+
validator: validator,
170+
logger: _logger,
171+
schemaGenerationContext: schemaContext);
172+
173+
// Copy required flag
174+
if (OpenApiSchemaCompatibility.RequiredContains(schema, schemaPropertyName))
175+
{
176+
#if OPENAPI_V2
177+
if (operationParameter is OpenApiParameter openApiParameter)
178+
openApiParameter.Required = true;
179+
#else
180+
operationParameter.Required = true;
181+
#endif
182+
}
183+
184+
// Copy validation constraints from schema property to parameter schema
185+
#if OPENAPI_V2
186+
var parameterSchema = operationParameter.Schema as OpenApiSchema;
187+
#else
188+
var parameterSchema = operationParameter.Schema;
189+
#endif
190+
if (parameterSchema != null)
191+
{
192+
if (OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName, out var property))
193+
{
194+
if (property != null)
195+
{
196+
CopyValidationProperties(property, parameterSchema);
197+
}
198+
}
199+
}
200+
}
201+
202+
/// <summary>
203+
/// Builds a temporary OpenApiSchema for a type by reflecting its properties.
204+
/// </summary>
205+
private OpenApiSchema BuildSchemaForType(Type type)
206+
{
207+
var schema = new OpenApiSchema();
208+
#if OPENAPI_V2
209+
var properties = new Dictionary<string, IOpenApiSchema>();
210+
#else
211+
var properties = new Dictionary<string, OpenApiSchema>();
212+
#endif
213+
214+
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
215+
{
216+
var propName = prop.Name;
217+
if (_schemaGenerationOptions.NameResolver != null)
218+
propName = _schemaGenerationOptions.NameResolver.GetPropertyName(prop);
219+
220+
var propSchema = new OpenApiSchema();
221+
SetSchemaType(propSchema, prop.PropertyType);
222+
properties[propName] = propSchema;
223+
}
224+
225+
schema.Properties = properties;
226+
return schema;
227+
}
228+
229+
/// <summary>
230+
/// Sets basic type information on a schema from a CLR type.
231+
/// TODO: Add support for DateTime, DateTimeOffset, Guid, enum types, and collections.
232+
/// Currently these all fall through to "string" which may cause incorrect schema types.
233+
/// </summary>
234+
private static void SetSchemaType(OpenApiSchema schema, Type type)
235+
{
236+
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
237+
238+
#if OPENAPI_V2
239+
if (underlyingType == typeof(int) || underlyingType == typeof(long) || underlyingType == typeof(short) || underlyingType == typeof(byte))
240+
schema.Type = JsonSchemaType.Integer;
241+
else if (underlyingType == typeof(float) || underlyingType == typeof(double) || underlyingType == typeof(decimal))
242+
schema.Type = JsonSchemaType.Number;
243+
else if (underlyingType == typeof(string))
244+
schema.Type = JsonSchemaType.String;
245+
else if (underlyingType == typeof(bool))
246+
schema.Type = JsonSchemaType.Boolean;
247+
else
248+
schema.Type = JsonSchemaType.String;
249+
#else
250+
if (underlyingType == typeof(int) || underlyingType == typeof(long) || underlyingType == typeof(short) || underlyingType == typeof(byte))
251+
schema.Type = "integer";
252+
else if (underlyingType == typeof(float) || underlyingType == typeof(double) || underlyingType == typeof(decimal))
253+
schema.Type = "number";
254+
else if (underlyingType == typeof(string))
255+
schema.Type = "string";
256+
else if (underlyingType == typeof(bool))
257+
schema.Type = "boolean";
258+
else
259+
schema.Type = "string";
260+
#endif
261+
}
262+
263+
/// <summary>
264+
/// Copies validation-related properties from source schema to target schema.
265+
/// </summary>
266+
private static void CopyValidationProperties(OpenApiSchema source, OpenApiSchema target)
267+
{
268+
if (source.MinLength != null) target.MinLength = source.MinLength;
269+
if (source.MaxLength != null) target.MaxLength = source.MaxLength;
270+
if (source.MinItems != null) target.MinItems = source.MinItems;
271+
if (source.MaxItems != null) target.MaxItems = source.MaxItems;
272+
if (source.Pattern != null) target.Pattern = source.Pattern;
273+
if (source.Minimum != null) target.Minimum = source.Minimum;
274+
if (source.Maximum != null) target.Maximum = source.Maximum;
275+
if (source.ExclusiveMinimum != null) target.ExclusiveMinimum = source.ExclusiveMinimum;
276+
if (source.ExclusiveMaximum != null) target.ExclusiveMaximum = source.ExclusiveMaximum;
277+
if (source.Format != null) target.Format = source.Format;
278+
}
279+
280+
/// <summary>
281+
/// Resolves the container type for a parameter by inspecting [AsParameters] on method parameters.
282+
/// For nested paths (e.g., "Filter.MinAge"), walks the path to find the correct container type.
283+
/// </summary>
284+
private static Type? ResolveContainerType(string parameterName, MethodInfo? methodInfo)
285+
{
286+
if (methodInfo == null)
287+
return null;
288+
289+
// Split dot-path into segments (e.g., "Filter.MinAge" => ["Filter", "MinAge"])
290+
var segments = parameterName.Split('.');
291+
292+
foreach (var param in methodInfo.GetParameters())
293+
{
294+
if (param.GetCustomAttribute<AsParametersAttribute>() == null)
295+
continue;
296+
297+
var paramType = param.ParameterType;
298+
299+
if (segments.Length == 1)
300+
{
301+
// Simple case: direct property on [AsParameters] type
302+
var property = paramType.GetProperties()
303+
.FirstOrDefault(p => p.Name.Equals(segments[0], StringComparison.OrdinalIgnoreCase));
304+
if (property != null)
305+
return paramType;
306+
}
307+
else
308+
{
309+
// Nested case: walk path segments to find the container of the leaf property.
310+
// Note: ASP.NET Core minimal APIs do not currently emit dot-path parameters
311+
// for nested [AsParameters] types. This branch is defensive code for potential
312+
// future framework support or MVC controller scenarios.
313+
// e.g., "Filter.MinAge" => find Filter on paramType, return Filter's type
314+
var currentType = paramType;
315+
for (int i = 0; i < segments.Length - 1; i++)
316+
{
317+
var navProp = currentType.GetProperties()
318+
.FirstOrDefault(p => p.Name.Equals(segments[i], StringComparison.OrdinalIgnoreCase));
319+
if (navProp == null)
320+
{
321+
currentType = null;
322+
break;
323+
}
324+
325+
currentType = navProp.PropertyType;
326+
}
327+
328+
if (currentType != null)
329+
{
330+
var leafProp = currentType.GetProperties()
331+
.FirstOrDefault(p => p.Name.Equals(segments[^1], StringComparison.OrdinalIgnoreCase));
332+
if (leafProp != null)
333+
return currentType;
334+
}
335+
}
336+
}
337+
338+
return null;
339+
}
340+
341+
/// <summary>
342+
/// Extracts MethodInfo from an ApiDescription.
343+
/// </summary>
344+
private static MethodInfo? GetMethodInfo(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription apiDescription)
345+
{
346+
return apiDescription.ActionDescriptor?.EndpointMetadata?
347+
.OfType<MethodInfo>()
348+
.FirstOrDefault();
349+
}
350+
}
351+
}

0 commit comments

Comments
 (0)