From 48f6a3d93d42cfc7e375bd542cd68fa5839fb2b6 Mon Sep 17 00:00:00 2001 From: Matan Tsach Date: Tue, 17 Mar 2026 14:50:40 +0200 Subject: [PATCH 1/3] Reflect primary constructor parameter attributes in OpenAPI schemas For C# 12 class primary constructors, validation attributes like [Range], [Required], [MinLength] etc. exist only on the constructor parameter, not on the synthesized property. The OpenAPI schema generator reads attributes from JsonPropertyInfo.AttributeProvider (the PropertyInfo) and never sees them. Check JsonPropertyInfo.AssociatedParameter.AttributeProvider as a fallback for constructor parameter attributes. Property-level attributes still take precedence (applied second, overwriting). Also extends the TypeInfoResolver modifier to check constructor parameter attributes for [Required]. Fixes #61538 --- .../Services/Schemas/OpenApiSchemaService.cs | 43 ++++++++- .../OpenApiSchemaService.PropertySchemas.cs | 92 +++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs index eec394066f9d..e3ee5319f0c1 100644 --- a/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs +++ b/src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs @@ -45,8 +45,16 @@ internal sealed class OpenApiSchemaService( { var hasRequiredAttribute = propertyInfo.AttributeProvider? .GetCustomAttributes(inherit: false) - .Any(attr => attr is RequiredAttribute); - propertyInfo.IsRequired |= hasRequiredAttribute ?? false; + .Any(attr => attr is RequiredAttribute) + ?? false; + // Also check constructor parameter attributes for primary constructor classes + // where attributes are on the parameter, not the synthesized property. + hasRequiredAttribute = hasRequiredAttribute + || (propertyInfo.AssociatedParameter?.AttributeProvider? + .GetCustomAttributes(inherit: false) + .Any(attr => attr is RequiredAttribute) + ?? false); + propertyInfo.IsRequired |= hasRequiredAttribute; } }) }; @@ -104,6 +112,37 @@ internal sealed class OpenApiSchemaService( { schema[OpenApiSchemaKeywords.DescriptionKeyword] = typeDescriptionAttribute.Description; } + // Apply constructor parameter attributes first for primary constructor classes + // where attributes are on the parameter, not the synthesized property. + // Property-level attributes applied below will override these. + if (context.PropertyInfo is { AssociatedParameter.AttributeProvider: { } parameterAttributeProvider }) + { + var parameterAttributes = parameterAttributeProvider.GetCustomAttributes(inherit: false); + if (parameterAttributes.OfType() is { } paramValidationAttributes) + { + schema.ApplyValidationAttributes(paramValidationAttributes); + } + if (parameterAttributes.OfType().LastOrDefault() is { } paramDefaultValueAttribute) + { + schema.ApplyDefaultValue(paramDefaultValueAttribute.Value, context.TypeInfo); + } + var isInlinedParamSchema = !schema.WillBeComponentized(); + if (isInlinedParamSchema) + { + if (parameterAttributes.OfType().LastOrDefault() is { } paramDescriptionAttribute) + { + schema[OpenApiSchemaKeywords.DescriptionKeyword] = paramDescriptionAttribute.Description; + } + } + else + { + if (parameterAttributes.OfType().LastOrDefault() is { } paramDescriptionAttribute) + { + schema[OpenApiConstants.RefDescriptionAnnotation] = paramDescriptionAttribute.Description; + } + } + } + // Property-level attributes override constructor parameter attributes. if (context.PropertyInfo is { AttributeProvider: { } attributeProvider }) { var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false); diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs index e3b021b22294..9ca689537182 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs @@ -641,7 +641,99 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiSchema_HandlesValidationAttributesOnPrimaryConstructorParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (PrimaryCtorWithValidationAttributes model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Paths["/api"].Operations[HttpMethod.Post].RequestBody.Content.First().Value.Schema; + + // [Range(0, 120)] on constructor parameter should produce minimum/maximum + var ageProperty = schema.Properties["age"]; + Assert.Equal(JsonSchemaType.Integer, ageProperty.Type); + Assert.Equal("0", ageProperty.Minimum); + Assert.Equal("120", ageProperty.Maximum); + + // [MinLength(1), MaxLength(100)] on constructor parameter should produce minLength/maxLength + var nameProperty = schema.Properties["name"]; + Assert.Equal(JsonSchemaType.String, nameProperty.Type); + Assert.Equal(1, nameProperty.MinLength); + Assert.Equal(100, nameProperty.MaxLength); + }); + } + + [Fact] + public async Task GetOpenApiSchema_HandlesRequiredAttributeOnPrimaryConstructorParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (PrimaryCtorWithRequiredAttribute model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Paths["/api"].Operations[HttpMethod.Post].RequestBody.Content.First().Value.Schema; + + // [Required] on constructor parameter should mark the property as required + Assert.Contains("email", schema.Required); + }); + } + + [Fact] + public async Task GetOpenApiSchema_PropertyAttributeOverridesConstructorParameterAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (PrimaryCtorWithPropertyOverride model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Paths["/api"].Operations[HttpMethod.Post].RequestBody.Content.First().Value.Schema; + + // Property-level [Range(10, 50)] should override constructor parameter [Range(0, 100)] + var scoreProperty = schema.Properties["score"]; + Assert.Equal("10", scoreProperty.Minimum); + Assert.Equal("50", scoreProperty.Maximum); + }); + } + #nullable enable + // Primary constructor class (NOT a record) with validation attributes on constructor parameters. + // Unlike records, C# does not synthesize property attributes for class primary constructors, + // so these attributes are only on the ParameterInfo, not the PropertyInfo. + private class PrimaryCtorWithValidationAttributes( + [Range(0, 120)] int age, + [MinLength(1), MaxLength(100)] string name) + { + public int Age { get; set; } = age; + public string Name { get; set; } = name; + } + + private class PrimaryCtorWithRequiredAttribute( + [Required] string email) + { + public string Email { get; set; } = email; + } + + private class PrimaryCtorWithPropertyOverride( + [Range(0, 100)] int score) + { + [Range(10, 50)] + public int Score { get; set; } = score; + } + private class NullablePropertiesTestModel { public int? NullableInt { get; set; } From 192ff4b1e1f60dff899ac85d464013446abbe2dc Mon Sep 17 00:00:00 2001 From: Matan Tsach Date: Tue, 17 Mar 2026 14:54:11 +0200 Subject: [PATCH 2/3] Address review: strengthen Required test with negative assertion --- .../OpenApiSchemaService.PropertySchemas.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs index 9ca689537182..7e6dd3068def 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs @@ -685,6 +685,8 @@ await VerifyOpenApiDocument(builder, document => // [Required] on constructor parameter should mark the property as required Assert.Contains("email", schema.Required); + // Property without [Required] should not be in the required list + Assert.DoesNotContain("optionalNote", schema.Required); }); } @@ -722,9 +724,11 @@ private class PrimaryCtorWithValidationAttributes( } private class PrimaryCtorWithRequiredAttribute( - [Required] string email) + [Required] string email, + string? optionalNote = null) { public string Email { get; set; } = email; + public string? OptionalNote { get; set; } = optionalNote; } private class PrimaryCtorWithPropertyOverride( From 9d19315838d034240dd441867cdf13e793eaf511 Mon Sep 17 00:00:00 2001 From: Matan Tsach Date: Tue, 17 Mar 2026 15:01:59 +0200 Subject: [PATCH 3/3] Add record regression, description, and edge case tests --- .../OpenApiSchemaService.PropertySchemas.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs index 7e6dd3068def..613813ef9d24 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.PropertySchemas.cs @@ -711,6 +711,52 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task GetOpenApiSchema_HandlesDescriptionAttributeOnPrimaryConstructorParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (PrimaryCtorWithDescription model) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Paths["/api"].Operations[HttpMethod.Post].RequestBody.Content.First().Value.Schema; + + // [Description] on constructor parameter should appear in schema + var ageProperty = schema.Properties["age"]; + Assert.Equal("The user's age in years", ageProperty.Description); + }); + } + + [Fact] + public async Task GetOpenApiSchema_RecordWithValidationAttributesStillWorks() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api", (RecordWithValidationAttributes model) => { }); + + // Assert — records already had this working; verify no regression from the + // constructor-parameter fallback applying attributes a second time. + await VerifyOpenApiDocument(builder, document => + { + var schema = document.Paths["/api"].Operations[HttpMethod.Post].RequestBody.Content.First().Value.Schema; + + var scoreProperty = schema.Properties["score"]; + Assert.Equal(JsonSchemaType.Integer, scoreProperty.Type); + Assert.Equal("1", scoreProperty.Minimum); + Assert.Equal("100", scoreProperty.Maximum); + + var nameProperty = schema.Properties["name"]; + Assert.Equal(JsonSchemaType.String, nameProperty.Type); + Assert.Equal(2, nameProperty.MinLength); + }); + } + #nullable enable // Primary constructor class (NOT a record) with validation attributes on constructor parameters. // Unlike records, C# does not synthesize property attributes for class primary constructors, @@ -738,6 +784,19 @@ private class PrimaryCtorWithPropertyOverride( public int Score { get; set; } = score; } + private class PrimaryCtorWithDescription( + [Description("The user's age in years")] int age) + { + public int Age { get; set; } = age; + } + + // Record type — the compiler copies constructor parameter attributes to the + // synthesized properties, so both AttributeProvider and AssociatedParameter + // return the same attributes. This test verifies no double-application regression. + private record RecordWithValidationAttributes( + [Range(1, 100)] int Score, + [MinLength(2)] string Name); + private class NullablePropertiesTestModel { public int? NullableInt { get; set; }