Skip to content
Merged
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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)
Expand All @@ -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();
}
}
},
Expand Down
7 changes: 4 additions & 3 deletions src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;

namespace MicroElements.OpenApi.Core
{
Expand All @@ -15,12 +16,12 @@
/// <summary>
/// Is supported swagger numeric type.
/// </summary>
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;

/// <summary>
/// Convert numeric to double.
/// Convert numeric to decimal.
/// </summary>
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);

/// <summary>
/// Returns not null enumeration.
Expand All @@ -41,7 +42,7 @@
#if DEBUG
return collection?.ToArray() ?? Array.Empty<TValue>();
#else
return collection;

Check warning on line 45 in src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Possible null reference return.

Check warning on line 45 in src/MicroElements.OpenApi.FluentValidation/Core/Extensions.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Possible null reference return.
#endif
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#else
using Microsoft.OpenApi.Models;
#endif
using Swashbuckle.AspNetCore.SwaggerGen;

namespace MicroElements.OpenApi
{
Expand Down Expand Up @@ -185,12 +186,14 @@ public static int PropertiesCount(OpenApiSchema schema)

/// <summary>
/// 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.
/// </summary>
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)
Expand All @@ -201,15 +204,18 @@ public static int PropertiesCount(OpenApiSchema schema)

/// <summary>
/// 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.
/// </summary>
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
Expand All @@ -223,6 +229,54 @@ public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiS
#endif
}

#if OPENAPI_V2
/// <summary>
/// Resolves a property that may be an <see cref="OpenApiSchemaReference"/> to an <see cref="OpenApiSchema"/>.
/// Issue #146, #176: BigInteger and enums are rendered as $ref on OpenAPI v2.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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

/// <summary>
/// Sets ExclusiveMinimum on schema.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#if !OPENAPI_V2
using Microsoft.OpenApi.Models;
#endif
using Swashbuckle.AspNetCore.SwaggerGen;

namespace MicroElements.Swashbuckle.FluentValidation
{
Expand Down Expand Up @@ -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
Expand All @@ -64,23 +69,28 @@ public OpenApiSchema Property
/// </summary>
private ValidationRuleContext ValidationRuleInfo { get; }

private readonly SchemaRepository? _schemaRepository;

/// <summary>
/// Initializes a new instance of the <see cref="OpenApiRuleContext"/> class.
/// </summary>
/// <param name="schema">Swagger schema.</param>
/// <param name="propertyKey">Property name.</param>
/// <param name="validationRuleInfo">ValidationRuleInfo.</param>
/// <param name="propertyValidator">Property validator.</param>
/// <param name="schemaRepository">Optional schema repository for resolving references.</param>
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;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public IRuleContext<OpenApiSchema> Create(
ValidationRuleContext validationRuleContext,
IPropertyValidator propertyValidator)
{
return new OpenApiRuleContext(Schema, schemaPropertyName, validationRuleContext, propertyValidator);
return new OpenApiRuleContext(Schema, schemaPropertyName, validationRuleContext, propertyValidator, SchemaRepository);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,8 @@ IEnumerable<ParameterItem> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,41 @@ public async Task ComparisonRules_ShouldApplyCorrectly()
quantity.GetProperty("maximum").GetInt32().Should().Be(1000);
}

/// <summary>
/// Issue #146: BigInteger properties should have validation constraints applied.
/// </summary>
[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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<TestBigIntegerModel>
{
public TestBigIntegerModelValidator()
{
RuleFor(x => x.Value).InclusiveBetween(new BigInteger(0), new BigInteger(999));
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
}
}
Loading
Loading