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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# 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
- Fix: snapshot `$ref` properties before rule application, restore them afterwards if no validation constraints were added by rules
- BigInteger per-model constraints (Issue #146) continue to work correctly

# 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiS
/// 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.
/// After rule application, call <see cref="RestoreUnmodifiedRefs"/> to restore $refs
/// for properties that were not modified by rules (Issue #198).
/// </summary>
public static OpenApiSchema? ResolveRefProperty(OpenApiSchema schema, string key, SchemaRepository? repository)
{
Expand All @@ -275,6 +277,98 @@ public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiS

return null;
}

/// <summary>
/// Snapshots all $ref properties in the schema before rule application.
/// Returns a dictionary of property key to original OpenApiSchemaReference.
/// </summary>
public static Dictionary<string, OpenApiSchemaReference>? SnapshotRefs(OpenApiSchema schema)
{
if (schema.Properties == null)
return null;

Dictionary<string, OpenApiSchemaReference>? snapshot = null;
foreach (var kvp in schema.Properties)
{
if (kvp.Value is OpenApiSchemaReference schemaRef)
{
snapshot ??= new Dictionary<string, OpenApiSchemaReference>();
snapshot[kvp.Key] = schemaRef;
}
}

return snapshot;
}

/// <summary>
/// Restores $ref properties that were replaced by ResolveRefProperty but not meaningfully
/// modified by validation rules. Compares the inline copy against the original component
/// schema to detect changes. Issue #198.
/// </summary>
public static void RestoreUnmodifiedRefs(
OpenApiSchema schema,
Dictionary<string, OpenApiSchemaReference>? snapshot,
SchemaRepository? repository)
{
if (snapshot == null || repository == null || schema.Properties == null)
return;

foreach (var kvp in snapshot)
{
var key = kvp.Key;
var originalRef = kvp.Value;

if (!schema.Properties.TryGetValue(key, out var currentProp))
continue;

// If still a ref, nothing was replaced
if (currentProp is OpenApiSchemaReference)
continue;

if (currentProp is OpenApiSchema inlineCopy)
{
var refId = originalRef.Reference?.Id;
if (refId != null && repository.Schemas.TryGetValue(refId, out var resolved) && resolved is OpenApiSchema componentSchema)
{
if (!HasValidationConstraintChanges(inlineCopy, componentSchema))
{
schema.Properties[key] = originalRef;
}
}
}
}
}

/// <summary>
/// Checks if the inline copy has any validation constraint differences compared to the component schema.
/// </summary>
private static bool HasValidationConstraintChanges(OpenApiSchema copy, OpenApiSchema original)
{
if (copy.MinLength != original.MinLength) return true;
if (copy.MaxLength != original.MaxLength) return true;
if (copy.MinItems != original.MinItems) return true;
if (copy.MaxItems != original.MaxItems) return true;
if (copy.Pattern != original.Pattern) return true;
if (copy.Minimum != original.Minimum) return true;
if (copy.Maximum != original.Maximum) return true;
if (copy.ExclusiveMinimum != original.ExclusiveMinimum) return true;
if (copy.ExclusiveMaximum != original.ExclusiveMaximum) return true;
if (copy.Type != original.Type) return true;
if (copy.Format != original.Format) return true;

// Check Required collection changes
var copyReq = copy.Required;
var origReq = original.Required;
if (copyReq?.Count != origReq?.Count) return true;
if (copyReq != null && origReq != null && !copyReq.SetEquals(origReq)) return true;

// Check AllOf collection changes (Pattern rule with UseAllOfForMultipleRules)
var copyAllOfCount = copy.AllOf?.Count ?? 0;
var origAllOfCount = original.AllOf?.Count ?? 0;
if (copyAllOfCount != origAllOfCount) return true;

return false;
}
#endif

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ public void Apply(OpenApiSchema? schema, SchemaFilterContext context)
{
foreach (var oneOfSchemas in allSchemas)
{
#if OPENAPI_V2
// Issue #198: Snapshot $ref properties before rule application.
// ResolveRefProperty replaces $refs with copies for mutation isolation (Issue #146),
// but we restore unmodified refs afterwards to preserve $ref structure.
var refSnapshot = OpenApiSchemaCompatibility.SnapshotRefs(oneOfSchemas);
#endif

var validatorContext = new ValidatorContext(typeContext, validator);
var schemaContext = new SchemaGenerationContext(
schemaRepository: context.SchemaRepository,
Expand All @@ -126,6 +133,11 @@ public void Apply(OpenApiSchema? schema, SchemaFilterContext context)
{
_logger.LogWarning(0, e, "Applying IncludeRules for type '{ModelType}' failed", context.Type);
}

#if OPENAPI_V2
// Issue #198: Restore $refs for properties that were not meaningfully modified by rules.
OpenApiSchemaCompatibility.RestoreUnmodifiedRefs(oneOfSchemas, refSnapshot, context.SchemaRepository);
#endif
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,101 @@ public ModelBValidator()
}
}

/// <summary>
/// Issue #198: SetValidator with nested object type should preserve $ref in parent schema.
/// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/198
/// </summary>
public class PersonModel
{
public AddressModel Address { get; set; }
}

public class AddressModel
{
public string Street { get; set; }
}

public class AddressModelValidator : AbstractValidator<AddressModel>
{
public AddressModelValidator()
{
RuleFor(x => x.Street).NotEmpty();
}
}

public class PersonModelValidator : AbstractValidator<PersonModel>
{
public PersonModelValidator()
{
RuleFor(x => x.Address)
.NotEmpty()
.SetValidator(new AddressModelValidator());
}
}

[Fact]
public void SetValidator_Should_Preserve_Ref_For_Nested_Object()
{
// Arrange
var schemaRepository = new SchemaRepository();
var schemaGenerator = SchemaGenerator(new PersonModelValidator(), new AddressModelValidator());

// Act
var referenceSchema = schemaGenerator.GenerateSchema(typeof(PersonModel), schemaRepository);
var personSchema = schemaRepository.GetSchema(referenceSchema.GetRefId()!);

// Assert: Person schema should have "Address" in required
personSchema.Required.Should().Contain("Address");

// Assert: Address component schema should exist and have street constraints
schemaRepository.Schemas.Should().ContainKey("AddressModel");

// Assert: Person.properties["Address"] should remain a $ref, not an inline copy
var addressProp = personSchema.Properties["Address"];
#if OPENAPI_V2
addressProp.Should().BeOfType<OpenApiSchemaReference>(
"Person.properties['address'] should be a $ref, not an inline copy of the Address schema");
#else
addressProp.Reference.Should().NotBeNull(
"Person.properties['address'] should be a $ref, not an inline copy of the Address schema");
#endif
}

/// <summary>
/// Issue #198 follow-up: when a $ref property IS modified by a validation rule
/// (e.g., Email sets Format), the $ref should NOT be restored — the inline copy
/// with constraints must be kept.
/// </summary>
public class ModelWithEmail
{
public string ContactEmail { get; set; }
}

public class ModelWithEmailValidator : AbstractValidator<ModelWithEmail>
{
public ModelWithEmailValidator()
{
RuleFor(x => x.ContactEmail).NotEmpty().EmailAddress();
}
}

[Fact]
public void Ref_Property_With_Constraint_Should_Not_Be_Restored()
{
// Arrange
var schemaRepository = new SchemaRepository();
var schemaGenerator = SchemaGenerator(new ModelWithEmailValidator());

// Act
var referenceSchema = schemaGenerator.GenerateSchema(typeof(ModelWithEmail), schemaRepository);
var schema = schemaRepository.GetSchema(referenceSchema.GetRefId()!);

// Assert: ContactEmail should have format=email applied (not discarded by ref restore)
var emailProp = schema.GetProperty("ContactEmail")!;
emailProp.Format.Should().Be("email",
"Email rule should set Format on the property, and it must not be discarded by $ref restoration");
}

[Fact]
public void SharedRef_Should_Not_Corrupt_Between_Models()
{
Expand Down
2 changes: 1 addition & 1 deletion version.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>7.1.2</VersionPrefix>
<VersionPrefix>7.1.3</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
Loading