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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Changes in 7.1.4-beta
- Added: `FluentValidationOperationTransformer` (`IOpenApiOperationTransformer`) for `MicroElements.AspNetCore.OpenApi.FluentValidation` (Issue #200)
- Query parameters with `[AsParameters]` now receive validation constraints (min/max, required, pattern, etc.)
- Supports container type resolution with fallback via reflection for `[AsParameters]`
- Copies validation constraints from schema properties to parameter schemas
- Registered automatically via `AddFluentValidationRules()`
- Fixed: Nested DTOs in request body not receiving validation constraints (Issue #200)
- `FluentValidationSchemaTransformer` skipped all property-level schemas, but for nested object types this was the only transformer call
- Now processes property-level schemas for complex types using the property type's validator

# Changes in 7.1.3
- Fixed: `$ref` replaced with inline schema copy when using `SetValidator` with nested object types (Issue #198)
- `ResolveRefProperty` (introduced in 7.1.2 for BigInteger isolation) replaced all `$ref` properties with copies, destroying reference structure in the OpenAPI document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static class OpenApiOptionsExtensions
public static OpenApiOptions AddFluentValidationRules(this OpenApiOptions options)
{
options.AddSchemaTransformer<FluentValidationSchemaTransformer>();
options.AddOperationTransformer<FluentValidationOperationTransformer>();
return options;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static IServiceCollection AddFluentValidationRulesToOpenApi(
// Transient (not Scoped) because .NET 10 build-time document generation
// runs without an HTTP scope, causing scoped resolution to fail.
services.TryAddTransient<FluentValidationSchemaTransformer>();
services.TryAddTransient<FluentValidationOperationTransformer>();

// Register JsonSerializerOptions (reference to Microsoft.AspNetCore.Mvc.JsonOptions.Value)
services.TryAddTransient<AspNetJsonSerializerOptions>(provider => new AspNetJsonSerializerOptions(provider.GetJsonSerializerOptionsOrDefault()));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
// Copyright (c) MicroElements. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using FluentValidation;
using MicroElements.OpenApi;
using MicroElements.OpenApi.Core;
using MicroElements.OpenApi.FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
#if OPENAPI_V2
using Microsoft.OpenApi;
#else
using Microsoft.OpenApi.Models;
#endif

namespace MicroElements.AspNetCore.OpenApi.FluentValidation
{
/// <summary>
/// <see cref="IOpenApiOperationTransformer"/> that applies FluentValidation rules
/// to query, route, and header parameters generated by Microsoft.AspNetCore.OpenApi.
/// Issue #200: Query parameters with [AsParameters] were not getting validation constraints.
/// </summary>
public class FluentValidationOperationTransformer : IOpenApiOperationTransformer
{
private readonly ILogger _logger;
private readonly IValidatorRegistry _validatorRegistry;
private readonly IReadOnlyList<IFluentValidationRule<OpenApiSchema>> _rules;
private readonly SchemaGenerationOptions _schemaGenerationOptions;

/// <summary>
/// Initializes a new instance of the <see cref="FluentValidationOperationTransformer"/> class.
/// </summary>
public FluentValidationOperationTransformer(
ILoggerFactory? loggerFactory = null,
IValidatorRegistry? validatorRegistry = null,
IFluentValidationRuleProvider<OpenApiSchema>? fluentValidationRuleProvider = null,
IEnumerable<FluentValidationRule>? rules = null,
IOptions<SchemaGenerationOptions>? schemaGenerationOptions = null)
{
_logger = loggerFactory?.CreateLogger(typeof(FluentValidationOperationTransformer)) ?? NullLogger.Instance;
_validatorRegistry = validatorRegistry ?? throw new ArgumentNullException(nameof(validatorRegistry));

fluentValidationRuleProvider ??= new DefaultFluentValidationRuleProvider(schemaGenerationOptions);
_rules = fluentValidationRuleProvider.GetRules().ToArray().OverrideRules(rules);
_schemaGenerationOptions = schemaGenerationOptions?.Value ?? new SchemaGenerationOptions();

_logger.LogDebug("FluentValidationOperationTransformer Created");
}

/// <inheritdoc />
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
try
{
if (operation.Parameters != null && operation.Parameters.Count > 0)
{
ApplyRulesToParameters(operation, context);
}
}
catch (Exception e)
{
_logger.LogWarning(0, e, "Error applying FluentValidation rules to operation parameters");
}

return Task.CompletedTask;
}

private void ApplyRulesToParameters(OpenApiOperation operation, OpenApiOperationTransformerContext context)
{
// Group parameters by container type to avoid redundant validator lookups and schema builds.
var parameterGroups = new Dictionary<Type, (IValidator Validator, OpenApiSchema Schema)>();

foreach (var operationParameter in operation.Parameters)
{
var apiParameterDescription = context.Description.ParameterDescriptions
.FirstOrDefault(d => d.Name.Equals(operationParameter.Name, StringComparison.InvariantCultureIgnoreCase));

var modelMetadata = apiParameterDescription?.ModelMetadata;
if (modelMetadata == null)
continue;

var parameterType = modelMetadata.ContainerType;

// Fallback: resolve container type from [AsParameters] attribute
if (parameterType == null)
{
var methodInfo = GetMethodInfo(context.Description);
parameterType = ResolveContainerType(operationParameter.Name, methodInfo);
}

if (parameterType == null)
continue;

// Reuse validator and schema for the same container type
if (!parameterGroups.TryGetValue(parameterType, out var cached))
{
var validator = _validatorRegistry.GetValidator(parameterType);
if (validator == null)
continue;

var schema = BuildSchemaForType(parameterType);
if (schema.Properties == null || schema.Properties.Count == 0)
continue;

cached = (validator, schema);
parameterGroups[parameterType] = cached;
}

ApplyRulesToParameter(operationParameter, parameterType, cached.Validator, cached.Schema);
}
}

private void ApplyRulesToParameter(
#if OPENAPI_V2
IOpenApiParameter operationParameter,
#else
OpenApiParameter operationParameter,
#endif
Type parameterType,
IValidator validator,
OpenApiSchema schema)
{
var schemaPropertyName = operationParameter.Name;

// For nested [FromQuery] parameters (e.g., "operation.op"), use only the leaf name
var dotIndex = schemaPropertyName.LastIndexOf('.');
if (dotIndex >= 0)
schemaPropertyName = schemaPropertyName.Substring(dotIndex + 1);

// Find matching property in schema
var apiProperty = OpenApiSchemaCompatibility.GetProperties(schema)
.FirstOrDefault(property => property.Key.EqualsIgnoreAll(schemaPropertyName));
if (apiProperty.Key != null)
{
schemaPropertyName = apiProperty.Key;
}
else
{
var propertyInfo = parameterType.GetProperty(schemaPropertyName);
if (propertyInfo != null && _schemaGenerationOptions.NameResolver != null)
{
schemaPropertyName = _schemaGenerationOptions.NameResolver.GetPropertyName(propertyInfo);
}
}

var schemaProvider = new AspNetCoreSchemaProvider(null, _logger);
var schemaContext = new AspNetCoreSchemaGenerationContext(
schema: schema,
schemaType: parameterType,
rules: _rules,
schemaGenerationOptions: _schemaGenerationOptions,
schemaProvider: schemaProvider);

FluentValidationSchemaBuilder.ApplyRulesToSchema(
schemaType: parameterType,
schemaPropertyNames: new[] { schemaPropertyName },
validator: validator,
logger: _logger,
schemaGenerationContext: schemaContext);

// Copy required flag
if (OpenApiSchemaCompatibility.RequiredContains(schema, schemaPropertyName))
{
#if OPENAPI_V2
if (operationParameter is OpenApiParameter openApiParameter)
openApiParameter.Required = true;
#else
operationParameter.Required = true;
#endif
}

// Copy validation constraints from schema property to parameter schema
#if OPENAPI_V2
var parameterSchema = operationParameter.Schema as OpenApiSchema;
#else
var parameterSchema = operationParameter.Schema;
#endif
if (parameterSchema != null)
{
if (OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName, out var property))
{
if (property != null)
{
CopyValidationProperties(property, parameterSchema);
}
}
}
}

/// <summary>
/// Builds a temporary OpenApiSchema for a type by reflecting its properties.
/// </summary>
private OpenApiSchema BuildSchemaForType(Type type)
{
var schema = new OpenApiSchema();
#if OPENAPI_V2
var properties = new Dictionary<string, IOpenApiSchema>();
#else
var properties = new Dictionary<string, OpenApiSchema>();
#endif

foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var propName = prop.Name;
if (_schemaGenerationOptions.NameResolver != null)
propName = _schemaGenerationOptions.NameResolver.GetPropertyName(prop);

var propSchema = new OpenApiSchema();
SetSchemaType(propSchema, prop.PropertyType);
properties[propName] = propSchema;
}

schema.Properties = properties;
return schema;
}

/// <summary>
/// Sets basic type information on a schema from a CLR type.
/// TODO: Add support for DateTime, DateTimeOffset, Guid, enum types, and collections.
/// Currently these all fall through to "string" which may cause incorrect schema types.
/// </summary>
private static void SetSchemaType(OpenApiSchema schema, Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;

#if OPENAPI_V2
if (underlyingType == typeof(int) || underlyingType == typeof(long) || underlyingType == typeof(short) || underlyingType == typeof(byte))
schema.Type = JsonSchemaType.Integer;
else if (underlyingType == typeof(float) || underlyingType == typeof(double) || underlyingType == typeof(decimal))
schema.Type = JsonSchemaType.Number;
else if (underlyingType == typeof(string))
schema.Type = JsonSchemaType.String;
else if (underlyingType == typeof(bool))
schema.Type = JsonSchemaType.Boolean;
else
schema.Type = JsonSchemaType.String;
#else
if (underlyingType == typeof(int) || underlyingType == typeof(long) || underlyingType == typeof(short) || underlyingType == typeof(byte))
schema.Type = "integer";
else if (underlyingType == typeof(float) || underlyingType == typeof(double) || underlyingType == typeof(decimal))
schema.Type = "number";
else if (underlyingType == typeof(string))
schema.Type = "string";
else if (underlyingType == typeof(bool))
schema.Type = "boolean";
else
schema.Type = "string";
#endif
}

/// <summary>
/// Copies validation-related properties from source schema to target schema.
/// </summary>
private static void CopyValidationProperties(OpenApiSchema source, OpenApiSchema target)
{
if (source.MinLength != null) target.MinLength = source.MinLength;
if (source.MaxLength != null) target.MaxLength = source.MaxLength;
if (source.MinItems != null) target.MinItems = source.MinItems;
if (source.MaxItems != null) target.MaxItems = source.MaxItems;
if (source.Pattern != null) target.Pattern = source.Pattern;
if (source.Minimum != null) target.Minimum = source.Minimum;
if (source.Maximum != null) target.Maximum = source.Maximum;
if (source.ExclusiveMinimum != null) target.ExclusiveMinimum = source.ExclusiveMinimum;
if (source.ExclusiveMaximum != null) target.ExclusiveMaximum = source.ExclusiveMaximum;
if (source.Format != null) target.Format = source.Format;
}

/// <summary>
/// Resolves the container type for a parameter by inspecting [AsParameters] on method parameters.
/// For nested paths (e.g., "Filter.MinAge"), walks the path to find the correct container type.
/// </summary>
private static Type? ResolveContainerType(string parameterName, MethodInfo? methodInfo)
{
if (methodInfo == null)
return null;

// Split dot-path into segments (e.g., "Filter.MinAge" => ["Filter", "MinAge"])
var segments = parameterName.Split('.');

foreach (var param in methodInfo.GetParameters())
{
if (param.GetCustomAttribute<AsParametersAttribute>() == null)
continue;

var paramType = param.ParameterType;

if (segments.Length == 1)
{
// Simple case: direct property on [AsParameters] type
var property = paramType.GetProperties()
.FirstOrDefault(p => p.Name.Equals(segments[0], StringComparison.OrdinalIgnoreCase));
if (property != null)
return paramType;
}
else
{
// Nested case: walk path segments to find the container of the leaf property.
// Note: ASP.NET Core minimal APIs do not currently emit dot-path parameters
// for nested [AsParameters] types. This branch is defensive code for potential
// future framework support or MVC controller scenarios.
// e.g., "Filter.MinAge" => find Filter on paramType, return Filter's type
var currentType = paramType;
for (int i = 0; i < segments.Length - 1; i++)
{
var navProp = currentType.GetProperties()
.FirstOrDefault(p => p.Name.Equals(segments[i], StringComparison.OrdinalIgnoreCase));
if (navProp == null)
{
currentType = null;
break;
}

currentType = navProp.PropertyType;
}

if (currentType != null)
{
var leafProp = currentType.GetProperties()
.FirstOrDefault(p => p.Name.Equals(segments[^1], StringComparison.OrdinalIgnoreCase));
if (leafProp != null)
return currentType;
}
}
}

return null;
}

/// <summary>
/// Extracts MethodInfo from an ApiDescription.
/// </summary>
private static MethodInfo? GetMethodInfo(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription apiDescription)
{
return apiDescription.ActionDescriptor?.EndpointMetadata?
.OfType<MethodInfo>()
.FirstOrDefault();
}
}
}
Loading
Loading