From 5e0c94a4c6eb9a4f491364a3d36ed24429ae92a3 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Tue, 24 Mar 2026 10:30:41 +0300 Subject: [PATCH 1/3] Fix $ref replaced with inline schema copy (Issue #198) ResolveRefProperty replaces $ref with shallow copies for mutation isolation (Issue #146), but this destroyed $ref structure for nested object types like Address. Fix: snapshot $ref properties before rule application and restore them afterwards if no validation constraints were added by rules. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../OpenApi/OpenApiSchemaCompatibility.cs | 88 +++++++++++++++++++ .../Swashbuckle/FluentValidationRules.cs | 12 +++ .../SchemaReferenceResolutionTests.cs | 60 +++++++++++++ 3 files changed, 160 insertions(+) diff --git a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs index 0c82b5c..e0d87d7 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs @@ -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 to restore $refs + /// for properties that were not modified by rules (Issue #198). /// public static OpenApiSchema? ResolveRefProperty(OpenApiSchema schema, string key, SchemaRepository? repository) { @@ -275,6 +277,92 @@ public static bool TryGetProperty(OpenApiSchema schema, string key, out OpenApiS return null; } + + /// + /// Snapshots all $ref properties in the schema before rule application. + /// Returns a dictionary of property key to original OpenApiSchemaReference. + /// + public static Dictionary? SnapshotRefs(OpenApiSchema schema) + { + if (schema.Properties == null) + return null; + + Dictionary? snapshot = null; + foreach (var kvp in schema.Properties) + { + if (kvp.Value is OpenApiSchemaReference schemaRef) + { + snapshot ??= new Dictionary(); + snapshot[kvp.Key] = schemaRef; + } + } + + return snapshot; + } + + /// + /// 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. + /// + public static void RestoreUnmodifiedRefs( + OpenApiSchema schema, + Dictionary? 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; + } + } + } + } + } + + /// + /// Checks if the inline copy has any validation constraint differences compared to the component schema. + /// + 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; + + // 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; + + return false; + } #endif /// diff --git a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationRules.cs b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationRules.cs index 02bb407..5c4200e 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationRules.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/Swashbuckle/FluentValidationRules.cs @@ -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, @@ -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 } } } diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs index c5b3973..058c000 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs @@ -123,6 +123,66 @@ public ModelBValidator() } } + /// + /// Issue #198: SetValidator with nested object type should preserve $ref in parent schema. + /// https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation/issues/198 + /// + public class PersonModel + { + public AddressModel Address { get; set; } + } + + public class AddressModel + { + public string Street { get; set; } + } + + public class AddressModelValidator : AbstractValidator + { + public AddressModelValidator() + { + RuleFor(x => x.Street).NotEmpty(); + } + } + + public class PersonModelValidator : AbstractValidator + { + 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( + "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 + } + [Fact] public void SharedRef_Should_Not_Corrupt_Between_Models() { From ce82f89c799c2288645296ed1110d2c9a7294739 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Tue, 24 Mar 2026 10:38:23 +0300 Subject: [PATCH 2/3] Bump version to 7.1.3 and update changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 ++++++ version.props | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 942c85a..a6fd78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/version.props b/version.props index f9cd927..9342ff1 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 7.1.2 + 7.1.3 From 6d26a4c7900e8b9957961afb7933ab1dc59397ec Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Tue, 24 Mar 2026 10:48:59 +0300 Subject: [PATCH 3/3] Add Format and AllOf checks to HasValidationConstraintChanges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #199 review: Email rule sets Format and Pattern rule with UseAllOfForMultipleRules appends to AllOf — both must be detected to prevent silent discard during $ref restoration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../OpenApi/OpenApiSchemaCompatibility.cs | 6 ++++ .../SchemaReferenceResolutionTests.cs | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs index e0d87d7..d6aead7 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/OpenApi/OpenApiSchemaCompatibility.cs @@ -354,6 +354,7 @@ private static bool HasValidationConstraintChanges(OpenApiSchema copy, OpenApiSc 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; @@ -361,6 +362,11 @@ private static bool HasValidationConstraintChanges(OpenApiSchema copy, OpenApiSc 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 diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs index 058c000..d9ae91d 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/SchemaReferenceResolutionTests.cs @@ -183,6 +183,41 @@ public void SetValidator_Should_Preserve_Ref_For_Nested_Object() #endif } + /// + /// 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. + /// + public class ModelWithEmail + { + public string ContactEmail { get; set; } + } + + public class ModelWithEmailValidator : AbstractValidator + { + 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() {