diff --git a/CHANGELOG.md b/CHANGELOG.md index a6fd78d..e5a5e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs index bba9b70..a4cd4d2 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/OpenApiOptionsExtensions.cs @@ -19,6 +19,7 @@ public static class OpenApiOptionsExtensions public static OpenApiOptions AddFluentValidationRules(this OpenApiOptions options) { options.AddSchemaTransformer(); + options.AddOperationTransformer(); return options; } } diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs index 4b3c119..c750c5c 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs @@ -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(); + services.TryAddTransient(); // Register JsonSerializerOptions (reference to Microsoft.AspNetCore.Mvc.JsonOptions.Value) services.TryAddTransient(provider => new AspNetJsonSerializerOptions(provider.GetJsonSerializerOptionsOrDefault())); diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs new file mode 100644 index 0000000..3945ee9 --- /dev/null +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationOperationTransformer.cs @@ -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 +{ + /// + /// 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. + /// + public class FluentValidationOperationTransformer : IOpenApiOperationTransformer + { + private readonly ILogger _logger; + private readonly IValidatorRegistry _validatorRegistry; + private readonly IReadOnlyList> _rules; + private readonly SchemaGenerationOptions _schemaGenerationOptions; + + /// + /// Initializes a new instance of the class. + /// + public FluentValidationOperationTransformer( + ILoggerFactory? loggerFactory = null, + IValidatorRegistry? validatorRegistry = null, + IFluentValidationRuleProvider? fluentValidationRuleProvider = null, + IEnumerable? rules = null, + IOptions? 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"); + } + + /// + 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(); + + 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); + } + } + } + } + + /// + /// Builds a temporary OpenApiSchema for a type by reflecting its properties. + /// + private OpenApiSchema BuildSchemaForType(Type type) + { + var schema = new OpenApiSchema(); +#if OPENAPI_V2 + var properties = new Dictionary(); +#else + var properties = new Dictionary(); +#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; + } + + /// + /// 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. + /// + 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 + } + + /// + /// Copies validation-related properties from source schema to target schema. + /// + 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; + } + + /// + /// 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. + /// + 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() == 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; + } + + /// + /// Extracts MethodInfo from an ApiDescription. + /// + private static MethodInfo? GetMethodInfo(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription apiDescription) + { + return apiDescription.ActionDescriptor?.EndpointMetadata? + .OfType() + .FirstOrDefault(); + } + } +} diff --git a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs index 7f46338..80f946f 100644 --- a/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs +++ b/src/MicroElements.AspNetCore.OpenApi.FluentValidation/FluentValidationSchemaTransformer.cs @@ -77,10 +77,18 @@ public Task TransformAsync( if (type.IsPrimitiveType()) return Task.CompletedTask; - // Skip property-level schemas (we process only type-level schemas). - // IOpenApiSchemaTransformer is called for each schema including property schemas. + // For property-level schemas, only process if the property type is a complex object + // with its own validator. This handles nested DTOs (Issue #200). + // Simple properties (string, int) are handled by the parent type's validator. if (context.JsonPropertyInfo != null) - return Task.CompletedTask; + { + var propertyType = context.JsonPropertyInfo.PropertyType; + if (propertyType.IsPrimitiveType() || propertyType == typeof(object)) + return Task.CompletedTask; + + // Use the property type for nested object schemas, not the parent type + type = propertyType; + } var typeContext = new TypeContext(type, _schemaGenerationOptions); diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs index edac7be..a469779 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs @@ -120,6 +120,216 @@ public async Task BigIntegerProperty_ShouldHaveValidationConstraints() } } + /// + /// Issue #200: Query parameters with [AsParameters] should have validation constraints. + /// + [Fact] + public async Task QueryParameters_WithAsParameters_ShouldHaveValidationConstraints() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/openapi/v1.json"); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + + // Find the /api/search GET operation + var searchPath = doc.RootElement.GetProperty("paths").GetProperty("/api/search").GetProperty("get"); + var parameters = searchPath.GetProperty("parameters"); + + // Find Skip and Take parameters + JsonElement? skipParam = null, takeParam = null; + foreach (var param in parameters.EnumerateArray()) + { + var name = param.GetProperty("name").GetString(); + if (name == "Skip") skipParam = param; + if (name == "Take") takeParam = param; + } + + skipParam.Should().NotBeNull("Skip parameter should exist"); + takeParam.Should().NotBeNull("Take parameter should exist"); + + // Skip: GreaterThanOrEqualTo(0) => minimum: 0 + var skipSchema = skipParam!.Value.GetProperty("schema"); + skipSchema.GetProperty("minimum").GetInt32().Should().Be(0); + + // Take: InclusiveBetween(1, 100) => minimum: 1, maximum: 100 + var takeSchema = takeParam!.Value.GetProperty("schema"); + takeSchema.GetProperty("minimum").GetInt32().Should().Be(1); + takeSchema.GetProperty("maximum").GetInt32().Should().Be(100); + } + + /// + /// Issue #200 (part 2): Nested DTOs in request body should have validation constraints. + /// Known limitation: schema transformer does not apply rules to nested component schemas yet. + /// + [Fact] + public async Task NestedDto_ShouldHaveValidationConstraints() + { + var schemas = await GetSchemasAsync(); + + var createAccount = schemas.GetProperty("TestCreateAccount"); + var props = createAccount.GetProperty("properties"); + + var email = props.GetProperty("email"); + email.GetProperty("minLength").GetInt32().Should().Be(1); + email.GetProperty("format").GetString().Should().Be("email"); + + var username = props.GetProperty("username"); + username.GetProperty("minLength").GetInt32().Should().Be(1); + username.GetProperty("maxLength").GetInt32().Should().Be(50); + } + + /// + /// Regression: existing top-level schema validation (TestCustomer) must still work + /// after adding property-level schema processing for nested DTOs. + /// + [Fact] + public async Task TopLevelSchema_ShouldStillHaveValidationConstraints() + { + var schemas = await GetSchemasAsync(); + + // TestCustomer constraints should still be applied + var customer = schemas.GetProperty("TestCustomer"); + var props = customer.GetProperty("properties"); + + // Age: GreaterThanOrEqualTo(0), LessThanOrEqualTo(150) + var age = props.GetProperty("age"); + age.GetProperty("minimum").GetInt32().Should().Be(0); + age.GetProperty("maximum").GetInt32().Should().Be(150); + + // Required array should still contain expected fields + var required = customer.GetProperty("required"); + var requiredValues = required.EnumerateArray().Select(e => e.GetString()).ToArray(); + requiredValues.Should().Contain("firstName"); + requiredValues.Should().Contain("email"); + } + + /// + /// Regression: parent DTO should still have its own validation applied + /// when it contains nested objects. + /// + [Fact] + public async Task ParentDto_WithNestedObject_ShouldHaveOwnValidation() + { + var schemas = await GetSchemasAsync(); + + var request = schemas.GetProperty("TestRequestWithNested"); + var props = request.GetProperty("properties"); + + // Name: NotEmpty + MaximumLength(100) => minLength: 1, maxLength: 100 + var name = props.GetProperty("name"); + name.GetProperty("minLength").GetInt32().Should().Be(1); + name.GetProperty("maxLength").GetInt32().Should().Be(100); + + // Required should contain "name" + var required = request.GetProperty("required"); + var requiredValues = required.EnumerateArray().Select(e => e.GetString()).ToArray(); + requiredValues.Should().Contain("name"); + } + + /// + /// Regression: nested object property should remain a $ref, not be inlined. + /// + [Fact] + public async Task NestedObjectProperty_ShouldRemainRef() + { + var schemas = await GetSchemasAsync(); + + var request = schemas.GetProperty("TestRequestWithNested"); + var account = request.GetProperty("properties").GetProperty("account"); + + // Account should be a $ref, not an inline object + account.TryGetProperty("$ref", out var refValue).Should().BeTrue( + "Nested object property should be a $ref"); + refValue.GetString().Should().Contain("TestCreateAccount"); + } + + /// + /// Regression: enum property should not cause errors after schema transformer changes. + /// + [Fact] + public async Task EnumProperty_ShouldStillWork() + { + var schemas = await GetSchemasAsync(); + + schemas.TryGetProperty("TestCustomerType", out _).Should().BeTrue(); + + // Customer should still have validation despite enum property + var customer = schemas.GetProperty("TestCustomer"); + customer.GetProperty("properties").GetProperty("firstName") + .GetProperty("minLength").GetInt32().Should().Be(1); + } + + /// + /// Regression: query parameters should not pollute component schemas. + /// + [Fact] + public async Task QueryParameters_ShouldNotAppearInComponentSchemas() + { + var schemas = await GetSchemasAsync(); + + // TestQueryParameters should NOT appear in component schemas + // (it's expanded into individual parameters by [AsParameters]) + schemas.TryGetProperty("TestQueryParameters", out _).Should().BeFalse( + "Query parameter container type should not appear in component schemas"); + } + + /// + /// Issue #200: Direct [AsParameters] should have validation constraints. + /// + [Fact] + public async Task DirectAsParameters_ShouldHaveValidationConstraints() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/openapi/v1.json"); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + + var filterPath = doc.RootElement.GetProperty("paths").GetProperty("/api/filter").GetProperty("get"); + var parameters = filterPath.GetProperty("parameters"); + + JsonElement? minAgeParam = null, maxAgeParam = null; + foreach (var param in parameters.EnumerateArray()) + { + var name = param.GetProperty("name").GetString(); + if (name == "MinAge" || name == "minAge") minAgeParam = param; + if (name == "MaxAge" || name == "maxAge") maxAgeParam = param; + } + + minAgeParam.Should().NotBeNull("MinAge parameter should exist"); + maxAgeParam.Should().NotBeNull("MaxAge parameter should exist"); + + // MinAge: GreaterThanOrEqualTo(0) => minimum: 0 + var minAgeSchema = minAgeParam!.Value.GetProperty("schema"); + minAgeSchema.GetProperty("minimum").GetInt32().Should().Be(0); + + // MaxAge: LessThanOrEqualTo(200) => maximum: 200 + var maxAgeSchema = maxAgeParam!.Value.GetProperty("schema"); + maxAgeSchema.GetProperty("maximum").GetInt32().Should().Be(200); + } + + /// + /// Issue #200 review (point 5): Collection constraints (MinItems/MaxItems) + /// should be applied and copied correctly. + /// + [Fact] + public async Task CollectionConstraints_ShouldBeApplied() + { + var schemas = await GetSchemasAsync(); + + var model = schemas.GetProperty("TestCollectionModel"); + var props = model.GetProperty("properties"); + + // Tags: NotEmpty => minItems: 1 + var tags = props.GetProperty("tags"); + tags.GetProperty("minItems").GetInt32().Should().Be(1); + + // Scores: NotEmpty => minItems: 1 + var scores = props.GetProperty("scores"); + scores.GetProperty("minItems").GetInt32().Should().Be(1); + } + [Fact] public void TransformerCanResolveWithoutScope() { diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs index a74c526..b279bd6 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs @@ -3,6 +3,7 @@ using FluentValidation; using MicroElements.AspNetCore.OpenApi.FluentValidation; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -18,5 +19,9 @@ app.MapPost("/api/customers", (TestCustomer customer) => Results.Ok(customer)); app.MapPost("/api/orders", (TestOrder order) => Results.Ok(order)); app.MapPost("/api/biginteger", (TestBigIntegerModel model) => Results.Ok(model)); +app.MapGet("/api/search", ([AsParameters] TestQueryParameters query) => Results.Ok(query)); +app.MapGet("/api/filter", ([AsParameters] TestFilterParams filter) => Results.Ok(filter)); +app.MapPost("/api/request", (TestRequestWithNested dto) => Results.Ok(dto)); +app.MapPost("/api/collections", (TestCollectionModel model) => Results.Ok(model)); app.Run(); diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs index 29480bd..dc99b88 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs @@ -54,6 +54,83 @@ public TestOrderValidator() } } +// Issue #200: Query parameters with [AsParameters] +public class TestQueryParameters +{ + public int Skip { get; set; } + public int Take { get; set; } +} + +public class TestQueryParametersValidator : AbstractValidator +{ + public TestQueryParametersValidator() + { + RuleFor(x => x.Skip).GreaterThanOrEqualTo(0); + RuleFor(x => x.Take).InclusiveBetween(1, 100); + } +} + +// Issue #200: Nested DTOs in request body +public record TestRequestWithNested +{ + public string Name { get; init; } = string.Empty; + public required TestCreateAccount Account { get; init; } +} + +public record TestCreateAccount +{ + public string Email { get; init; } = string.Empty; + public string Username { get; init; } = string.Empty; +} + +public class TestRequestWithNestedValidator : AbstractValidator +{ + public TestRequestWithNestedValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + } +} + +public class TestCreateAccountValidator : AbstractValidator +{ + public TestCreateAccountValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.Username).NotEmpty().MaximumLength(50); + } +} + +public class TestFilterParams +{ + public int MinAge { get; set; } + public int MaxAge { get; set; } +} + +public class TestFilterParamsValidator : AbstractValidator +{ + public TestFilterParamsValidator() + { + RuleFor(x => x.MinAge).GreaterThanOrEqualTo(0); + RuleFor(x => x.MaxAge).LessThanOrEqualTo(200); + } +} + +// Issue #200: Collection constraints (MinItems/MaxItems) +public class TestCollectionModel +{ + public List Tags { get; set; } = new(); + public List Scores { get; set; } = new(); +} + +public class TestCollectionModelValidator : AbstractValidator +{ + public TestCollectionModelValidator() + { + RuleFor(x => x.Tags).NotEmpty(); // minItems: 1 + RuleFor(x => x.Scores).NotEmpty(); // minItems: 1 + } +} + // BigInteger model for Issue #146 public class TestBigIntegerModel { diff --git a/version.props b/version.props index 9342ff1..3f4b710 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.1.3 - + 7.1.4 + beta