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..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 @@ -641,7 +641,162 @@ 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); + // Property without [Required] should not be in the required list + Assert.DoesNotContain("optionalNote", 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); + }); + } + + [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, + // 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, + string? optionalNote = null) + { + public string Email { get; set; } = email; + public string? OptionalNote { get; set; } = optionalNote; + } + + private class PrimaryCtorWithPropertyOverride( + [Range(0, 100)] int score) + { + [Range(10, 50)] + 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; }