Skip to content
Open
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
43 changes: 41 additions & 2 deletions src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
})
};
Expand Down Expand Up @@ -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<ValidationAttribute>() is { } paramValidationAttributes)
{
schema.ApplyValidationAttributes(paramValidationAttributes);
}
Comment on lines +121 to +124
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — though this pattern is inherited from the existing property-level block just below (line 151), which uses the same OfType<ValidationAttribute>() is { } guard. I kept it consistent with the surrounding code, but happy to clean up both if the team prefers.

if (parameterAttributes.OfType<DefaultValueAttribute>().LastOrDefault() is { } paramDefaultValueAttribute)
{
schema.ApplyDefaultValue(paramDefaultValueAttribute.Value, context.TypeInfo);
}
var isInlinedParamSchema = !schema.WillBeComponentized();
if (isInlinedParamSchema)
{
if (parameterAttributes.OfType<DescriptionAttribute>().LastOrDefault() is { } paramDescriptionAttribute)
{
schema[OpenApiSchemaKeywords.DescriptionKeyword] = paramDescriptionAttribute.Description;
}
}
else
{
if (parameterAttributes.OfType<DescriptionAttribute>().LastOrDefault() is { } paramDescriptionAttribute)
{
schema[OpenApiConstants.RefDescriptionAnnotation] = paramDescriptionAttribute.Description;
}
Comment on lines +129 to +142
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same story here — the existing property-level block (lines 153-165) has the same duplicated DescriptionAttribute lookup across the inline/componentized branches. Kept it parallel for consistency, but I can consolidate both blocks if the team would like that as part of this PR.

}
}
// Property-level attributes override constructor parameter attributes.
if (context.PropertyInfo is { AttributeProvider: { } attributeProvider })
{
var propertyAttributes = attributeProvider.GetCustomAttributes(inherit: false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading