diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/OpenApiSchemaExtensions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/OpenApiSchemaExtensions.cs index 1a7bf31e09..c99f14adfe 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/OpenApiSchemaExtensions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/OpenApiSchemaExtensions.cs @@ -185,6 +185,10 @@ private static void ApplyMinLengthAttribute(OpenApiSchema schema, MinLengthAttri { schema.MinItems = minLengthAttribute.Length; } + else if (schema.Type is { } objectType && objectType.HasFlag(JsonSchemaTypes.Object)) + { + schema.MinProperties = minLengthAttribute.Length; + } else { schema.MinLength = minLengthAttribute.Length; @@ -209,6 +213,10 @@ private static void ApplyMaxLengthAttribute(OpenApiSchema schema, MaxLengthAttri { schema.MaxItems = maxLengthAttribute.Length; } + else if (schema.Type is { } objectType && objectType.HasFlag(JsonSchemaTypes.Object)) + { + schema.MaxProperties = maxLengthAttribute.Length; + } else { schema.MaxLength = maxLengthAttribute.Length; @@ -234,6 +242,11 @@ private static void ApplyLengthAttribute(OpenApiSchema schema, LengthAttribute l schema.MinItems = lengthAttribute.MinimumLength; schema.MaxItems = lengthAttribute.MaximumLength; } + else if (schema.Type is { } objectType && objectType.HasFlag(JsonSchemaTypes.Object)) + { + schema.MinProperties = lengthAttribute.MinimumLength; + schema.MaxProperties = lengthAttribute.MaximumLength; + } else { schema.MinLength = lengthAttribute.MinimumLength; diff --git a/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs index 6c0fbb3552..2af269e777 100644 --- a/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs @@ -373,10 +373,16 @@ public void GenerateSchema_SetsValidationProperties_IfComplexTypeHasValidationAt Assert.Equal(3, schema.Properties["StringWithMinMaxLength"].MaxLength); Assert.Equal(1, schema.Properties["ArrayWithMinMaxLength"].MinItems); Assert.Equal(3, schema.Properties["ArrayWithMinMaxLength"].MaxItems); + Assert.Equal(1, schema.Properties["BoundedReadOnlyDictionary"].MinProperties); + Assert.Equal(3, schema.Properties["BoundedReadOnlyDictionary"].MaxProperties); + Assert.Null(schema.Properties["BoundedReadOnlyDictionary"].MinLength); + Assert.Null(schema.Properties["BoundedReadOnlyDictionary"].MaxLength); Assert.Equal(1, schema.Properties["StringWithLength"].MinLength); Assert.Equal(3, schema.Properties["StringWithLength"].MaxLength); Assert.Equal(1, schema.Properties["ArrayWithLength"].MinItems); Assert.Equal(3, schema.Properties["ArrayWithLength"].MaxItems); + Assert.Equal(1, schema.Properties["BoundedDictionary"].MinProperties); + Assert.Equal(3, schema.Properties["BoundedDictionary"].MaxProperties); Assert.NotNull(schema.Properties["IntWithExclusiveRange"].ExclusiveMinimum); Assert.NotNull(schema.Properties["IntWithExclusiveRange"].ExclusiveMaximum); Assert.Equal("byte", schema.Properties["StringWithBase64"].Format); diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index d866a7b40c..801067c991 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -373,10 +373,16 @@ public void GenerateSchema_SetsValidationProperties_IfComplexTypeHasValidationAt Assert.Equal(3, schema.Properties["StringWithMinMaxLength"].MaxLength); Assert.Equal(1, schema.Properties["ArrayWithMinMaxLength"].MinItems); Assert.Equal(3, schema.Properties["ArrayWithMinMaxLength"].MaxItems); + Assert.Equal(1, schema.Properties["BoundedReadOnlyDictionary"].MinProperties); + Assert.Equal(3, schema.Properties["BoundedReadOnlyDictionary"].MaxProperties); + Assert.Null(schema.Properties["BoundedReadOnlyDictionary"].MinLength); + Assert.Null(schema.Properties["BoundedReadOnlyDictionary"].MaxLength); Assert.Equal(1, schema.Properties["StringWithLength"].MinLength); Assert.Equal(3, schema.Properties["StringWithLength"].MaxLength); Assert.Equal(1, schema.Properties["ArrayWithLength"].MinItems); Assert.Equal(3, schema.Properties["ArrayWithLength"].MaxItems); + Assert.Equal(1, schema.Properties["BoundedDictionary"].MinProperties); + Assert.Equal(3, schema.Properties["BoundedDictionary"].MaxProperties); Assert.NotNull(schema.Properties["IntWithExclusiveRange"].ExclusiveMinimum); Assert.NotNull(schema.Properties["IntWithExclusiveRange"].ExclusiveMaximum); Assert.Equal("byte", schema.Properties["StringWithBase64"].Format); @@ -397,6 +403,33 @@ public void GenerateSchema_SetsValidationProperties_IfComplexTypeHasValidationAt Assert.True(schema.Properties[nameof(TypeWithValidationAttributes.StringWithReadOnly)].ReadOnly); } + [Theory] + [InlineData(typeof(TypeWithValidationAttributes))] + [InlineData(typeof(TypeWithValidationAttributesViaMetadataType))] + public void GenerateSchema_BoundedDictionarys_OpenApiJsonUsesMinAndMaxProperties(Type type) + { + // Arrange + var schemaRepository = new SchemaRepository(); + Subject().GenerateSchema(type, schemaRepository); + + var document = new OpenApiDocument + { + Components = new OpenApiComponents { Schemas = schemaRepository.Schemas }, + }; + + // Act - serialize to OpenAPI 3.0 JSON, the same writer used by the runtime + using var writer = new StringWriter(); + var jsonWriter = new OpenApiJsonWriter(writer); + document.SerializeAsV3(jsonWriter); + var json = writer.ToString(); + + // Assert - the dictionary constraints surface as minProperties/maxProperties + // in the generated OpenAPI document. Before the fix these would have been + // emitted as minLength/maxLength on an object schema, which is invalid per spec. + Assert.Contains("\"minProperties\": 1", json); + Assert.Contains("\"maxProperties\": 3", json); + } + [Fact] public void GenerateSchema_SetsReadOnlyAndWriteOnlyFlags_IfPropertyIsRestricted() { diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/OpenApiSchemaExtensionsTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/OpenApiSchemaExtensionsTests.cs index 75e4f5fd8c..251d13b247 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/OpenApiSchemaExtensionsTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/OpenApiSchemaExtensionsTests.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Globalization; using Microsoft.OpenApi; @@ -149,6 +150,189 @@ public static void ApplyValidationAttributes_Handles_DataTypeAttribute_CustomDat Assert.Equal(customDataType, schema.Format); } + [Fact] + public static void ApplyValidationAttributes_MinLength_On_Dictionary_Maps_To_MinProperties() + { + // Arrange - dictionary schema is represented as an Object with AdditionalProperties + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + AdditionalPropertiesAllowed = true, + AdditionalProperties = new OpenApiSchema { Type = JsonSchemaType.String }, + }; + + // Act + schema.ApplyValidationAttributes([new MinLengthAttribute(1)]); + + // Assert + Assert.Equal(1, schema.MinProperties); + Assert.Null(schema.MinLength); + Assert.Null(schema.MinItems); + } + + [Fact] + public static void ApplyValidationAttributes_MaxLength_On_Dictionary_Maps_To_MaxProperties() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + AdditionalPropertiesAllowed = true, + AdditionalProperties = new OpenApiSchema { Type = JsonSchemaType.String }, + }; + + // Act + schema.ApplyValidationAttributes([new MaxLengthAttribute(10)]); + + // Assert + Assert.Equal(10, schema.MaxProperties); + Assert.Null(schema.MaxLength); + Assert.Null(schema.MaxItems); + } + + [Fact] + public static void ApplyValidationAttributes_Length_On_Dictionary_Maps_To_Min_And_MaxProperties() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + AdditionalPropertiesAllowed = true, + AdditionalProperties = new OpenApiSchema { Type = JsonSchemaType.String }, + }; + + // Act + schema.ApplyValidationAttributes([new LengthAttribute(2, 5)]); + + // Assert + Assert.Equal(2, schema.MinProperties); + Assert.Equal(5, schema.MaxProperties); + Assert.Null(schema.MinLength); + Assert.Null(schema.MaxLength); + Assert.Null(schema.MinItems); + Assert.Null(schema.MaxItems); + } + + [Fact] + public static void ApplyValidationAttributes_MinLength_On_String_Still_Maps_To_MinLength() + { + // Arrange - regression guard for the existing string path + var schema = new OpenApiSchema { Type = JsonSchemaType.String }; + + // Act + schema.ApplyValidationAttributes([new MinLengthAttribute(3)]); + + // Assert + Assert.Equal(3, schema.MinLength); + Assert.Null(schema.MinProperties); + Assert.Null(schema.MinItems); + } + + [Fact] + public static void ApplyValidationAttributes_MinLength_On_Array_Still_Maps_To_MinItems() + { + // Arrange - regression guard for the existing array path + var schema = new OpenApiSchema { Type = JsonSchemaType.Array }; + + // Act + schema.ApplyValidationAttributes([new MinLengthAttribute(3)]); + + // Assert + Assert.Equal(3, schema.MinItems); + Assert.Null(schema.MinProperties); + Assert.Null(schema.MinLength); + } + + [Fact] + public static void ApplyValidationAttributes_MaxLength_On_String_Still_Maps_To_MaxLength() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String }; + + schema.ApplyValidationAttributes([new MaxLengthAttribute(5)]); + + Assert.Equal(5, schema.MaxLength); + Assert.Null(schema.MaxProperties); + Assert.Null(schema.MaxItems); + } + + [Fact] + public static void ApplyValidationAttributes_MaxLength_On_Array_Still_Maps_To_MaxItems() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Array }; + + schema.ApplyValidationAttributes([new MaxLengthAttribute(5)]); + + Assert.Equal(5, schema.MaxItems); + Assert.Null(schema.MaxProperties); + Assert.Null(schema.MaxLength); + } + + [Fact] + public static void ApplyValidationAttributes_Length_On_String_Still_Maps_To_MinAndMaxLength() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.String }; + + schema.ApplyValidationAttributes([new LengthAttribute(1, 5)]); + + Assert.Equal(1, schema.MinLength); + Assert.Equal(5, schema.MaxLength); + Assert.Null(schema.MinProperties); + Assert.Null(schema.MaxProperties); + } + + [Fact] + public static void ApplyValidationAttributes_Length_On_Array_Still_Maps_To_MinAndMaxItems() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Array }; + + schema.ApplyValidationAttributes([new LengthAttribute(1, 5)]); + + Assert.Equal(1, schema.MinItems); + Assert.Equal(5, schema.MaxItems); + Assert.Null(schema.MinProperties); + Assert.Null(schema.MaxProperties); + } + + [Fact] + public static void ApplyValidationAttributes_MinLength_On_EnumKeyedDictionarySchema_Maps_To_MinProperties() + { + // Enum-keyed dictionaries are emitted as Object with known Properties and + // AdditionalPropertiesAllowed = false (see SchemaGenerator.CreateDictionarySchema). + // The fix must still route MinLength to MinProperties in this shape. + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["Foo"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["Bar"] = new OpenApiSchema { Type = JsonSchemaType.String }, + }, + AdditionalPropertiesAllowed = false, + }; + + schema.ApplyValidationAttributes([new MinLengthAttribute(1)]); + + Assert.Equal(1, schema.MinProperties); + Assert.Null(schema.MinLength); + } + + [Fact] + public static void ApplyValidationAttributes_MinLength_On_NullableObjectSchema_Maps_To_MinProperties() + { + // A nullable dictionary has Type = Object | Null. HasFlag(Object) must still route to MinProperties. + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object | JsonSchemaType.Null, + AdditionalPropertiesAllowed = true, + AdditionalProperties = new OpenApiSchema { Type = JsonSchemaType.String }, + }; + + schema.ApplyValidationAttributes([new MinLengthAttribute(2)]); + + Assert.Equal(2, schema.MinProperties); + Assert.Null(schema.MinLength); + } + private sealed class CultureSwitcher : IDisposable { private readonly CultureInfo _previous; diff --git a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributes.cs b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributes.cs index 7e066b9480..0610d1ef4c 100644 --- a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributes.cs +++ b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributes.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Swashbuckle.AspNetCore.TestSupport; @@ -14,12 +15,18 @@ public class TypeWithValidationAttributes [MinLength(1), MaxLength(3)] public string[] ArrayWithMinMaxLength { get; set; } + [MinLength(1), MaxLength(3)] + public IReadOnlyDictionary BoundedReadOnlyDictionary { get; set; } + [Length(1, 3)] public string StringWithLength { get; set; } [Length(1, 3)] public string[] ArrayWithLength { get; set; } + [Length(1, 3)] + public Dictionary BoundedDictionary { get; set; } + [Range(1, 10, MinimumIsExclusive = true, MaximumIsExclusive = true)] public int IntWithExclusiveRange { get; set; } diff --git a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributesViaMetadataType.cs b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributesViaMetadataType.cs index 735f944f4a..68632e06fd 100644 --- a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributesViaMetadataType.cs +++ b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithValidationAttributesViaMetadataType.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; @@ -13,10 +14,14 @@ public class TypeWithValidationAttributesViaMetadataType public string[] ArrayWithMinMaxLength { get; set; } + public IReadOnlyDictionary BoundedReadOnlyDictionary { get; set; } + public string StringWithLength { get; set; } public string[] ArrayWithLength { get; set; } + public Dictionary BoundedDictionary { get; set; } + public string StringWithBase64 { get; set; } public double IntWithExclusiveRange { get; set; } @@ -49,12 +54,18 @@ public class MetadataType [MinLength(1), MaxLength(3)] public string[] ArrayWithMinMaxLength { get; set; } + [MinLength(1), MaxLength(3)] + public IReadOnlyDictionary BoundedReadOnlyDictionary { get; set; } + [Length(1, 3)] public string StringWithLength { get; set; } [Length(1, 3)] public string[] ArrayWithLength { get; set; } + [Length(1, 3)] + public Dictionary BoundedDictionary { get; set; } + [Range(1, 10, MinimumIsExclusive = true, MaximumIsExclusive = true)] public int IntWithExclusiveRange { get; set; }