diff --git a/CHANGELOG.md b/CHANGELOG.md index fffe567..942c85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# Changes in 7.1.2 +- Added: `BigInteger` support for min/max validation constraints in OpenAPI schema generation (Issue #146) + - `IsNumeric()` and `NumericToDecimal()` now handle `BigInteger` values + - `BigInteger` properties with GreaterThan, LessThan, InclusiveBetween, ExclusiveBetween rules produce correct `minimum`/`maximum` in Swagger + - NSwag provider updated with the same `BigInteger` support + - Out-of-range `BigInteger` values (exceeding `decimal` range) are handled gracefully via existing try/catch +- Fixed: Shared schema mutation when multiple models reference the same `BigInteger` type with different constraints (net10.0) + - `ResolveRefProperty` creates an isolated shallow copy before applying rule mutations + - Prevents `$ref`-based schema corruption across models in `SchemaRepository` +- Fixed: Replaced deprecated `PackageLicenseUrl` with `PackageLicenseExpression` (Issue #144) +- Fixed: Replaced deprecated `PackageIconUrl` with embedded `PackageIcon` + # Changes in 7.1.1 - Fixed: Nested object validation not applied for `[FromQuery]` parameters (Issue #162) - When Swashbuckle decomposes `[FromQuery]` models with nested objects into flat parameters (e.g., `operation.op`), the full dot-path name was used for schema property matching instead of the leaf name (`op`) diff --git a/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs b/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs index c5d5e59..4cef4c9 100644 --- a/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs +++ b/src/MicroElements.NSwag.FluentValidation/NSwagFluentValidationRuleProvider.cs @@ -157,7 +157,7 @@ public FluentValidationRule[] CreateDefaultRules() if (comparisonValidator.ValueToCompare.IsNumeric()) { - var valueToCompare = Convert.ToDecimal(comparisonValidator.ValueToCompare); + var valueToCompare = comparisonValidator.ValueToCompare.NumericToDecimal(); var schema = context.Schema.Schema; var schemaProperty = schema.Properties[context.PropertyKey]; @@ -199,11 +199,11 @@ public FluentValidationRule[] CreateDefaultRules() { if (betweenValidator.GetType().IsSubClassOfGeneric(typeof(ExclusiveBetweenValidator<,>))) { - schemaProperty.ExclusiveMinimum = Convert.ToDecimal(betweenValidator.From); + schemaProperty.ExclusiveMinimum = betweenValidator.From.NumericToDecimal(); } else { - schemaProperty.Minimum = Convert.ToDecimal(betweenValidator.From); + schemaProperty.Minimum = betweenValidator.From.NumericToDecimal(); } if (ShouldSetNotNullableForNumericMinimum) @@ -214,11 +214,11 @@ public FluentValidationRule[] CreateDefaultRules() { if (betweenValidator.GetType().IsSubClassOfGeneric(typeof(ExclusiveBetweenValidator<,>))) { - schemaProperty.ExclusiveMaximum = Convert.ToDecimal(betweenValidator.To); + schemaProperty.ExclusiveMaximum = betweenValidator.To.NumericToDecimal(); } else { - schemaProperty.Maximum = Convert.ToDecimal(betweenValidator.To); + schemaProperty.Maximum = betweenValidator.To.NumericToDecimal(); } } }, diff --git a/src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs b/src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs index 454fcc5..aeea6e6 100644 --- a/src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs +++ b/src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; namespace MicroElements.OpenApi.Core { @@ -15,12 +16,12 @@ public static class Extensions /// /// Is supported swagger numeric type. /// - internal static bool IsNumeric(this object value) => value is int || value is long || value is float || value is double || value is decimal; + internal static bool IsNumeric(this object value) => value is int || value is long || value is float || value is double || value is decimal || value is BigInteger; /// - /// Convert numeric to double. + /// Convert numeric to decimal. /// - internal static decimal NumericToDecimal(this object value) => Convert.ToDecimal(value); + internal static decimal NumericToDecimal(this object value) => value is BigInteger bigInt ? (decimal)bigInt : Convert.ToDecimal(value); /// /// Returns not null enumeration. diff --git a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs index 0a40c3b..0c82b5c 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs @@ -10,6 +10,7 @@ #else using Microsoft.OpenApi.Models; #endif +using Swashbuckle.AspNetCore.SwaggerGen; namespace MicroElements.OpenApi { @@ -185,12 +186,14 @@ public static int PropertiesCount(OpenApiSchema schema) /// /// Gets property from schema by key. + /// On OPENAPI_V2, if the property is a $ref, resolves it via the repository and returns the shared schema. + /// The returned schema must not be mutated; use ResolveRefProperty when mutation isolation is needed. /// - public static OpenApiSchema? GetProperty(OpenApiSchema schema, string key) + public static OpenApiSchema? GetProperty(OpenApiSchema schema, string key, SchemaRepository? repository = null) { #if OPENAPI_V2 if (schema.Properties?.TryGetValue(key, out var property) == true) - return property as OpenApiSchema; // Returns null for OpenApiSchemaReference (e.g. enums) + return ResolveProperty(property, repository); return null; #else if (schema.Properties?.TryGetValue(key, out var property) == true) @@ -201,15 +204,18 @@ public static int PropertiesCount(OpenApiSchema schema) /// /// Tries to get property from schema by key. + /// On OPENAPI_V2, if the property is a $ref, resolves it via the repository and returns the shared schema. + /// The returned schema must not be mutated; use ResolveRefProperty when mutation isolation is needed. /// - public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiSchema? property) + public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiSchema? property, SchemaRepository? repository = null) { #if OPENAPI_V2 if (schema.Properties?.TryGetValue(key, out var prop) == true) { - property = prop as OpenApiSchema; + property = ResolveProperty(prop, repository); return property != null; } + property = null; return false; #else @@ -223,6 +229,54 @@ public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiS #endif } +#if OPENAPI_V2 + /// + /// Resolves a property that may be an to an . + /// Issue #146, #176: BigInteger and enums are rendered as $ref on OpenAPI v2. + /// + private static OpenApiSchema? ResolveProperty(IOpenApiSchema property, SchemaRepository? repository) + { + if (property is OpenApiSchema openApiSchema) + return openApiSchema; + + if (property is OpenApiSchemaReference schemaRef && repository != null) + { + var refId = schemaRef.Reference?.Id; + if (refId != null && repository.Schemas.TryGetValue(refId, out var resolved) && resolved is OpenApiSchema resolvedSchema) + return resolvedSchema; + } + + return null; + } + + /// + /// Resolves a $ref property, replaces it with an isolated shallow copy in the parent schema, + /// and returns the copy. If the property is already a concrete OpenApiSchema, returns it as-is. + /// This prevents validation rules from mutating the shared schema in SchemaRepository. + /// + public static OpenApiSchema? ResolveRefProperty(OpenApiSchema schema, string key, SchemaRepository? repository) + { + if (schema.Properties == null || !schema.Properties.TryGetValue(key, out var prop)) + return null; + + if (prop is OpenApiSchema openApiSchema) + return openApiSchema; + + if (prop is OpenApiSchemaReference schemaRef && repository != null) + { + var refId = schemaRef.Reference?.Id; + if (refId != null && repository.Schemas.TryGetValue(refId, out var resolved) && resolved is OpenApiSchema resolvedSchema) + { + var copy = (OpenApiSchema)resolvedSchema.CreateShallowCopy(); + schema.Properties[key] = copy; + return copy; + } + } + + return null; + } +#endif + /// /// Sets ExclusiveMinimum on schema. /// diff --git a/src/MicroElements.Swashbuckle.FluentValidation/OpenApiRuleContext.cs b/src/MicroElements.Swashbuckle.FluentValidation/OpenApiRuleContext.cs index 4bbf9e2..83147c1 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/OpenApiRuleContext.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/OpenApiRuleContext.cs @@ -9,6 +9,7 @@ #if !OPENAPI_V2 using Microsoft.OpenApi.Models; #endif +using Swashbuckle.AspNetCore.SwaggerGen; namespace MicroElements.Swashbuckle.FluentValidation { @@ -45,7 +46,11 @@ public OpenApiSchema Property throw new ApplicationException($"Schema for type '{schemaType}' does not contain property '{PropertyKey}'.\nRegister {typeof(INameResolver)} if name in type differs from name in json."); } +#if OPENAPI_V2 + var schemaProperty = OpenApiSchemaCompatibility.ResolveRefProperty(Schema, PropertyKey, _schemaRepository); +#else var schemaProperty = OpenApiSchemaCompatibility.GetProperty(Schema, PropertyKey); +#endif // Property is a schema reference (enum, nested class) - return empty schema to skip validation // Issue #176: https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/176 @@ -64,6 +69,8 @@ public OpenApiSchema Property /// private ValidationRuleContext ValidationRuleInfo { get; } + private readonly SchemaRepository? _schemaRepository; + /// /// Initializes a new instance of the class. /// @@ -71,16 +78,19 @@ public OpenApiSchema Property /// Property name. /// ValidationRuleInfo. /// Property validator. + /// Optional schema repository for resolving references. public OpenApiRuleContext( OpenApiSchema schema, string propertyKey, ValidationRuleContext validationRuleInfo, - IPropertyValidator propertyValidator) + IPropertyValidator propertyValidator, + SchemaRepository? schemaRepository = null) { Schema = schema; PropertyKey = propertyKey; ValidationRuleInfo = validationRuleInfo; PropertyValidator = propertyValidator; + _schemaRepository = schemaRepository; } } -} \ No newline at end of file +} diff --git a/src/MicroElements.Swashbuckle.FluentValidation/SchemaGenerationContext.cs b/src/MicroElements.Swashbuckle.FluentValidation/SchemaGenerationContext.cs index b69b1ff..098232b 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/SchemaGenerationContext.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/SchemaGenerationContext.cs @@ -67,7 +67,7 @@ public IRuleContext Create( ValidationRuleContext validationRuleContext, IPropertyValidator propertyValidator) { - return new OpenApiRuleContext(Schema, schemaPropertyName, validationRuleContext, propertyValidator); + return new OpenApiRuleContext(Schema, schemaPropertyName, validationRuleContext, propertyValidator, SchemaRepository); } /// diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationDocumentFilter.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationDocumentFilter.cs index 45f7605..879c343 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationDocumentFilter.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationDocumentFilter.cs @@ -217,8 +217,8 @@ IEnumerable GetParameters() var schema = item.Schema; if (schema != null && parameterSchema != null && schemaPropertyName != null) { - if (OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName.ToLowerCamelCase(), out var property) - || OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName, out property)) + if (OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName.ToLowerCamelCase(), out var property, context.SchemaRepository) + || OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName, out property, context.SchemaRepository)) { if (property != null) { diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs index 8992a6a..04cf160 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationOperationFilter.cs @@ -188,8 +188,8 @@ private void ApplyRulesToParameters(OpenApiOperation operation, OperationFilterC var parameterSchema = operationParameter.Schema; if (parameterSchema != null) { - if (OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName.ToLowerCamelCase(), out var property) - || OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName, out property)) + if (OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName.ToLowerCamelCase(), out var property, context.SchemaRepository) + || OpenApiSchemaCompatibility.TryGetProperty(schema, schemaPropertyName, out property, context.SchemaRepository)) { if (property != null) { diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs index 7d5dc34..edac7be 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/AspNetCoreOpenApiTests.cs @@ -85,6 +85,41 @@ public async Task ComparisonRules_ShouldApplyCorrectly() quantity.GetProperty("maximum").GetInt32().Should().Be(1000); } + /// + /// Issue #146: BigInteger properties should have validation constraints applied. + /// + [Fact] + public async Task BigIntegerProperty_ShouldHaveValidationConstraints() + { + var schemas = await GetSchemasAsync(); + + var bigIntModel = schemas.GetProperty("TestBigIntegerModel"); + var props = bigIntModel.GetProperty("properties"); + + // Name should have standard string constraints + var name = props.GetProperty("name"); + name.GetProperty("minLength").GetInt32().Should().Be(1); + name.GetProperty("maxLength").GetInt32().Should().Be(100); + + // Value (BigInteger): InclusiveBetween(0, 999) => minimum: 0, maximum: 999 + // In ASP.NET Core OpenAPI, BigInteger is serialized as a $ref to the component schema. + // The transformer applies validation rules to the shared component schema object, + // so constraints appear on the BigInteger component rather than inline on the property. + var value = props.GetProperty("value"); + if (value.TryGetProperty("$ref", out _)) + { + // Follow the $ref — constraints are on the BigInteger component schema + var bigInteger = schemas.GetProperty("BigInteger"); + bigInteger.GetProperty("minimum").GetInt32().Should().Be(0); + bigInteger.GetProperty("maximum").GetInt32().Should().Be(999); + } + else + { + value.GetProperty("minimum").GetInt32().Should().Be(0); + value.GetProperty("maximum").GetInt32().Should().Be(999); + } + } + [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 a56fda9..a74c526 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/Program.cs @@ -17,5 +17,6 @@ app.MapOpenApi(); 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.Run(); diff --git a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs index 7e85790..29480bd 100644 --- a/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs +++ b/test/MicroElements.AspNetCore.OpenApi.FluentValidation.Tests/TestModels.cs @@ -1,6 +1,7 @@ // Copyright (c) MicroElements. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Numerics; using FluentValidation; // Marker class for WebApplicationFactory @@ -52,3 +53,19 @@ public TestOrderValidator() RuleFor(x => x.Items).NotEmpty(); } } + +// BigInteger model for Issue #146 +public class TestBigIntegerModel +{ + public BigInteger Value { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class TestBigIntegerModelValidator : AbstractValidator +{ + public TestBigIntegerModelValidator() + { + RuleFor(x => x.Value).InclusiveBetween(new BigInteger(0), new BigInteger(999)); + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + } +} diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/BigIntegerParameterIntegrationTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/BigIntegerParameterIntegrationTests.cs new file mode 100644 index 0000000..4caeb0c --- /dev/null +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/BigIntegerParameterIntegrationTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using System.Net.Http; +using System.Numerics; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using FluentValidation; +using MicroElements.Swashbuckle.FluentValidation.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MicroElements.Swashbuckle.FluentValidation.Tests +{ + /// + /// Integration tests verifying that BigInteger validation rules are applied + /// to operation parameters (via OperationFilter/DocumentFilter) on all TFMs. + /// Issue #146: BigInteger rendered as $ref on net10.0 should still get validation constraints. + /// + public class BigIntegerParameterIntegrationTests + { + public record BigIntegerRequest(BigInteger Limit, string Name); + + public class BigIntegerRequestValidator : AbstractValidator + { + public BigIntegerRequestValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(new BigInteger(1), new BigInteger(1000)); + RuleFor(x => x.Name).NotEmpty().MaximumLength(50); + } + } + + private async Task<(JsonElement Doc, WebApplication App)> CreateAppAndGetSwaggerJson() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + builder.Services.AddFluentValidationRulesToSwagger(); + builder.Services.AddScoped, BigIntegerRequestValidator>(); + + var app = builder.Build(); + app.Urls.Add("http://127.0.0.1:0"); + app.UseSwagger(); + app.MapGet("/api/data", ([AsParameters] BigIntegerRequest request) => Results.Ok()); + + await app.StartAsync(); + + // Fetch Swagger JSON via HTTP to avoid OpenApi serialization API differences across TFMs + var address = app.Urls.First(); + using var client = new HttpClient { BaseAddress = new System.Uri(address) }; + var json = await client.GetStringAsync("/swagger/v1/swagger.json"); + var doc = JsonDocument.Parse(json); + + return (doc.RootElement, app); + } + + /// + /// Verifies that BigInteger [AsParameters] properties have validation constraints + /// applied in the Swagger output. This tests the full pipeline including + /// OperationFilter's TryGetProperty → copy step. + /// + [Fact] + public async Task BigInteger_AsParameters_Should_Have_Validation_Constraints() + { + var (doc, app) = await CreateAppAndGetSwaggerJson(); + + try + { + var parameters = doc.GetProperty("paths") + .GetProperty("/api/data") + .GetProperty("get") + .GetProperty("parameters"); + + // Find Limit parameter + JsonElement? limitParam = null; + JsonElement? nameParam = null; + foreach (var param in parameters.EnumerateArray()) + { + var name = param.GetProperty("name").GetString(); + if (name == "Limit" || name == "limit") limitParam = param; + if (name == "Name" || name == "name") nameParam = param; + } + + limitParam.Should().NotBeNull("Limit parameter should exist"); + nameParam.Should().NotBeNull("Name parameter should exist"); + + // Name should have standard string constraints (works on all TFMs) + var nameSchema = nameParam!.Value.GetProperty("schema"); + nameSchema.GetProperty("minLength").GetInt32().Should().Be(1, "NotEmpty sets minLength=1"); + nameSchema.GetProperty("maxLength").GetInt32().Should().Be(50, "MaximumLength(50) sets maxLength=50"); + + // Limit (BigInteger) should have min/max constraints + // This is the key assertion: on net10.0, BigInteger is $ref → TryGetProperty must resolve it + var limitSchema = limitParam!.Value.GetProperty("schema"); + limitSchema.TryGetProperty("minimum", out var minimum).Should().BeTrue( + "InclusiveBetween(1, 1000) should set minimum on BigInteger parameter"); + minimum.GetInt32().Should().Be(1); + + limitSchema.TryGetProperty("maximum", out var maximum).Should().BeTrue( + "InclusiveBetween(1, 1000) should set maximum on BigInteger parameter"); + maximum.GetInt32().Should().Be(1000); + } + finally + { + await app.StopAsync(); + } + } + } +} diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaGenerationTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaGenerationTests.cs index da37faf..d722c0a 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaGenerationTests.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaGenerationTests.cs @@ -12,6 +12,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Numerics; using System.Text.Json.Serialization; using Xunit; @@ -564,6 +565,74 @@ public void ILengthValidator_ProperlyAppliesMinMax_ToArrays(int min, int max) schema.GetProperty(nameof(MinMaxLength.Qualities))!.MaxItems.Should().BeNull(); } + /// + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/146 + /// BigInteger within decimal range should emit min/max constraints. + /// + public class BigIntegerModel + { + public BigInteger Value { get; set; } + } + + public class BigIntegerModelValidator : AbstractValidator + { + public BigIntegerModelValidator() + { + RuleFor(x => x.Value).InclusiveBetween(new BigInteger(0), new BigInteger(12345678900)); + } + } + + [Fact] + public void BigInteger_InclusiveBetween_Should_Set_MinMax() + { + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(new BigIntegerModelValidator()).GenerateSchema(typeof(BigIntegerModel), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + + var valueProp = schema.GetProperty("Value", schemaRepository)!; + valueProp.GetMinimum().Should().Be(0); + valueProp.GetMaximum().Should().Be(12345678900m); + } + + /// + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/146 + /// BigInteger exceeding decimal range should silently skip (no crash). + /// + [Fact] + public void BigInteger_ExceedingDecimalRange_Should_Not_Crash() + { + var overflowValue = BigInteger.Parse("999999999999999999999999999999999"); + var validator = new InlineValidator(); + validator.RuleFor(x => x.Value).InclusiveBetween(overflowValue, overflowValue); + + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(validator).GenerateSchema(typeof(BigIntegerModel), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + + // Should not crash; min/max should not be set (overflow on all TFMs) + schema.GetProperty("Value", schemaRepository)!.GetMinimum().Should().BeNull(); + schema.GetProperty("Value", schemaRepository)!.GetMaximum().Should().BeNull(); + } + + /// + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/146 + /// BigInteger GreaterThan should emit minimum constraint. + /// + [Fact] + public void BigInteger_GreaterThan_Should_Set_Minimum() + { + var validator = new InlineValidator(); + validator.RuleFor(x => x.Value).GreaterThan(new BigInteger(10)); + + var schemaRepository = new SchemaRepository(); + var referenceSchema = SchemaGenerator(validator).GenerateSchema(typeof(BigIntegerModel), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + + var valueProp = schema.GetProperty("Value", schemaRepository)!; + valueProp.GetMinimum().Should().Be(10); + valueProp.GetExclusiveMinimum().Should().Be(true); + } + [Fact] // See the issue https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/156 public void DerivedSample_ShouldHave_ValidationRulesApplied() diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs new file mode 100644 index 0000000..c5b3973 --- /dev/null +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Numerics; +using FluentAssertions; +using FluentValidation; +using MicroElements.OpenApi; +#if OPENAPI_V2 +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif +using Swashbuckle.AspNetCore.SwaggerGen; +using Xunit; + +namespace MicroElements.Swashbuckle.FluentValidation.Tests +{ + /// + /// Tests for OpenApiSchemaReference resolution in GetProperty/TryGetProperty. + /// Issue #146: BigInteger (and other $ref types) should have validation rules applied on net10.0. + /// + public class SchemaReferenceResolutionTests : UnitTestBase + { + /// + /// Verifies that OpenApiSchemaCompatibility.GetProperty resolves OpenApiSchemaReference + /// through SchemaRepository when the property is a $ref. + /// + [Fact] + public void GetProperty_Should_Resolve_SchemaReference_Via_Repository() + { + // Arrange: generate schema for a type that contains BigInteger + var schemaRepository = new SchemaRepository(); + var validator = new InlineValidator(); + validator.RuleFor(x => x.Value).InclusiveBetween(new BigInteger(0), new BigInteger(100)); + + var schemaGenerator = SchemaGenerator(validator); + var referenceSchema = schemaGenerator.GenerateSchema(typeof(SchemaGenerationTests.BigIntegerModel), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + + // Act: GetProperty WITH repository should resolve even if property is OpenApiSchemaReference + var property = OpenApiSchemaCompatibility.GetProperty(schema, "Value", schemaRepository); + + // Assert + property.Should().NotBeNull("GetProperty with repository should resolve $ref properties"); + } + + /// + /// Verifies that OpenApiSchemaCompatibility.TryGetProperty resolves OpenApiSchemaReference + /// through SchemaRepository when the property is a $ref. + /// + [Fact] + public void TryGetProperty_Should_Resolve_SchemaReference_Via_Repository() + { + // Arrange + var schemaRepository = new SchemaRepository(); + var validator = new InlineValidator(); + validator.RuleFor(x => x.Value).GreaterThan(new BigInteger(5)); + + var schemaGenerator = SchemaGenerator(validator); + var referenceSchema = schemaGenerator.GenerateSchema(typeof(SchemaGenerationTests.BigIntegerModel), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + + // Act + var found = OpenApiSchemaCompatibility.TryGetProperty(schema, "Value", out var property, schemaRepository); + + // Assert + found.Should().BeTrue("TryGetProperty with repository should resolve $ref properties"); + property.Should().NotBeNull(); + } + + /// + /// Verifies that GetProperty works after the schema filter has resolved $ref properties. + /// On OPENAPI_V2, the schema filter replaces $ref entries with concrete schemas during processing, + /// so GetProperty works even without a repository after the filter has run. + /// + [Fact] + public void GetProperty_Without_Repository_After_Filter_Processing() + { + // Arrange + var schemaRepository = new SchemaRepository(); + var validator = new InlineValidator(); + validator.RuleFor(x => x.Value).InclusiveBetween(new BigInteger(0), new BigInteger(100)); + + var schemaGenerator = SchemaGenerator(validator); + var referenceSchema = schemaGenerator.GenerateSchema(typeof(SchemaGenerationTests.BigIntegerModel), schemaRepository); + var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!); + + // Act: GetProperty WITHOUT repository — after the schema filter has already processed the schema, + // the $ref is replaced with a concrete OpenApiSchema, so it works without repository. + var property = OpenApiSchemaCompatibility.GetProperty(schema, "Value"); + + // After filter processing, the property should be available regardless of OPENAPI version + property.Should().NotBeNull("property should be available after schema filter processing"); + } + + /// + /// Two models sharing the same BigInteger $ref type but with different validator ranges + /// should get independent min/max constraints (no shared schema mutation). + /// + public class ModelA + { + public BigInteger Amount { get; set; } + } + + public class ModelB + { + public BigInteger Amount { get; set; } + } + + public class ModelAValidator : AbstractValidator + { + public ModelAValidator() + { + RuleFor(x => x.Amount).InclusiveBetween(new BigInteger(10), new BigInteger(100)); + } + } + + public class ModelBValidator : AbstractValidator + { + public ModelBValidator() + { + RuleFor(x => x.Amount).InclusiveBetween(new BigInteger(500), new BigInteger(1000)); + } + } + + [Fact] + public void SharedRef_Should_Not_Corrupt_Between_Models() + { + // Arrange: both models share BigInteger which may be a $ref in the SchemaRepository + var schemaRepository = new SchemaRepository(); + var schemaGenerator = SchemaGenerator(new ModelAValidator(), new ModelBValidator()); + + // Act: generate schemas for both models into the same repository + var refA = schemaGenerator.GenerateSchema(typeof(ModelA), schemaRepository); + var schemaA = schemaRepository.GetSchema(refA.GetRefId()!); + + var refB = schemaGenerator.GenerateSchema(typeof(ModelB), schemaRepository); + var schemaB = schemaRepository.GetSchema(refB.GetRefId()!); + + // Assert: each model should have its own min/max constraints + // Use OpenApiSchemaCompatibility.GetProperty which handles $ref resolution on OPENAPI_V2 + var propertyA = OpenApiSchemaCompatibility.GetProperty(schemaA, "Amount", schemaRepository); + var propertyB = OpenApiSchemaCompatibility.GetProperty(schemaB, "Amount", schemaRepository); + + propertyA.Should().NotBeNull("ModelA should have Amount property"); + propertyB.Should().NotBeNull("ModelB should have Amount property"); + + var minA = OpenApiSchemaCompatibility.GetMinimum(propertyA!); + var maxA = OpenApiSchemaCompatibility.GetMaximum(propertyA!); + var minB = OpenApiSchemaCompatibility.GetMinimum(propertyB!); + var maxB = OpenApiSchemaCompatibility.GetMaximum(propertyB!); + + minA.Should().Be(10, "ModelA minimum should be 10"); + maxA.Should().Be(100, "ModelA maximum should be 100"); + minB.Should().Be(500, "ModelB minimum should be 500"); + maxB.Should().Be(1000, "ModelB maximum should be 1000"); + } + } +} diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/TestCompatibility.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/TestCompatibility.cs index 1a9463f..42f079e 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/TestCompatibility.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/TestCompatibility.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using MicroElements.OpenApi; #if OPENAPI_V2 using Microsoft.OpenApi; #else @@ -115,19 +116,12 @@ public static bool IsNullable(this OpenApiSchema schema) } /// - /// Gets property from schema. + /// Gets property from schema. Delegates to production + /// to avoid duplicating $ref resolution logic. /// - public static OpenApiSchema? GetProperty(this OpenApiSchema schema, string key) + public static OpenApiSchema? GetProperty(this OpenApiSchema schema, string key, SchemaRepository? repository = null) { -#if OPENAPI_V2 - if (schema.Properties?.TryGetValue(key, out var prop) == true) - return prop as OpenApiSchema; - return null; -#else - if (schema.Properties?.TryGetValue(key, out var prop) == true) - return prop; - return null; -#endif + return OpenApiSchemaCompatibility.GetProperty(schema, key, repository); } /// diff --git a/version.props b/version.props index daa6c8b..f9cd927 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.1.1 + 7.1.2