diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs index fde8deaad9..c048d970ed 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs @@ -210,11 +210,13 @@ private List GetDataPropertiesFor(Type objectType, out Type extens // See https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-immutability?pivots=dotnet-6-0 var isDeserializedViaConstructor = false; + var isRequired = false; + #if NET5_0_OR_GREATER var deserializationConstructor = propertyInfo.DeclaringType?.GetConstructors() .OrderBy(c => { - if (c.GetCustomAttribute() != null) return 1; + if (c.GetCustomAttribute() != null) return 1; if (c.GetParameters().Length == 0) return 2; return 3; }) @@ -228,11 +230,14 @@ private List GetDataPropertiesFor(Type objectType, out Type extens string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase); }); #endif +#if NET7_0_OR_GREATER + isRequired = propertyInfo.GetCustomAttribute() != null; +#endif dataProperties.Add( new DataProperty( name: name, - isRequired: false, + isRequired: isRequired, isNullable: propertyInfo.PropertyType.IsReferenceOrNullableType(), isReadOnly: propertyInfo.IsPubliclyReadable() && !propertyInfo.IsPubliclyWritable() && !isDeserializedViaConstructor, isWriteOnly: propertyInfo.IsPubliclyWritable() && !propertyInfo.IsPubliclyReadable(), diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.SwaggerEndpoint_ReturnsValidSwaggerJson_For_WebApi_swaggerRequestUri=v1.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.SwaggerEndpoint_ReturnsValidSwaggerJson_For_WebApi_swaggerRequestUri=v1.verified.txt index 7349ea852c..a024a3215d 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.SwaggerEndpoint_ReturnsValidSwaggerJson_For_WebApi_swaggerRequestUri=v1.verified.txt +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.SwaggerEndpoint_ReturnsValidSwaggerJson_For_WebApi_swaggerRequestUri=v1.verified.txt @@ -330,6 +330,35 @@ } } }, + "/WithOpenApi/IFromBody": { + "post": { + "tags": [ + "WithOpenApi" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationCustomExchangeRatesDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/XmlComments/Car/{id}": { "get": { "tags": [ @@ -445,6 +474,28 @@ }, "additionalProperties": false }, + "CurrenciesRate": { + "required": [ + "currencyFrom", + "currencyTo" + ], + "type": "object", + "properties": { + "currencyFrom": { + "type": "string", + "nullable": true + }, + "currencyTo": { + "type": "string", + "nullable": true + }, + "rate": { + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, "DateTimeKind": { "enum": [ 0, @@ -465,6 +516,22 @@ "additionalProperties": false, "description": "Description for Schema" }, + "OrganizationCustomExchangeRatesDto": { + "required": [ + "currenciesRates" + ], + "type": "object", + "properties": { + "currenciesRates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CurrenciesRate" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "Person": { "type": "object", "properties": { diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.TypesAreRenderedCorrectly.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.TypesAreRenderedCorrectly.verified.txt index 7349ea852c..a024a3215d 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.TypesAreRenderedCorrectly.verified.txt +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerVerifyIntegrationTest.TypesAreRenderedCorrectly.verified.txt @@ -330,6 +330,35 @@ } } }, + "/WithOpenApi/IFromBody": { + "post": { + "tags": [ + "WithOpenApi" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationCustomExchangeRatesDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/XmlComments/Car/{id}": { "get": { "tags": [ @@ -445,6 +474,28 @@ }, "additionalProperties": false }, + "CurrenciesRate": { + "required": [ + "currencyFrom", + "currencyTo" + ], + "type": "object", + "properties": { + "currencyFrom": { + "type": "string", + "nullable": true + }, + "currencyTo": { + "type": "string", + "nullable": true + }, + "rate": { + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, "DateTimeKind": { "enum": [ 0, @@ -465,6 +516,22 @@ "additionalProperties": false, "description": "Description for Schema" }, + "OrganizationCustomExchangeRatesDto": { + "required": [ + "currenciesRates" + ], + "type": "object", + "properties": { + "currenciesRates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CurrenciesRate" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "Person": { "type": "object", "properties": { diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/JsonRequiredAnnotatedType.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/JsonRequiredAnnotatedType.cs new file mode 100644 index 0000000000..200394acc6 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/JsonRequiredAnnotatedType.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Swashbuckle.AspNetCore.SwaggerGen.Test.Fixtures; + +internal class JsonRequiredAnnotatedType +{ + +#if NET7_0_OR_GREATER + [JsonRequired] +#endif + public string StringWithJsonRequired { get; set; } +} diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index 11d1be49a4..6e2c10240a 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -885,6 +885,20 @@ public void GenerateSchema_HonorsSerializerAttribute_JsonPropertyName() Assert.Equal(new[] { "string-with-json-property-name" }, schema.Properties.Keys.ToArray()); } +#if NET7_0_OR_GREATER + [Fact] + public void GenerateSchema_HonorsSerializerAttribute_JsonRequired() + { + var schemaRepository = new SchemaRepository(); + + var referenceSchema = Subject().GenerateSchema(typeof(JsonRequiredAnnotatedType), schemaRepository); + + var schema = schemaRepository.Schemas[referenceSchema.Reference.Id]; + Assert.Equal(new[] { "StringWithJsonRequired" }, schema.Required.ToArray()); + Assert.True(schema.Properties["StringWithJsonRequired"].Nullable); + } +#endif + [Fact] public void GenerateSchema_HonorsSerializerAttribute_JsonExtensionData() { diff --git a/test/WebSites/WebApi/EndPoints/OpenApiEndpoints.cs b/test/WebSites/WebApi/EndPoints/OpenApiEndpoints.cs index 49fd09297f..f8c22e9244 100644 --- a/test/WebSites/WebApi/EndPoints/OpenApiEndpoints.cs +++ b/test/WebSites/WebApi/EndPoints/OpenApiEndpoints.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; namespace WebApi.EndPoints { @@ -51,6 +52,11 @@ public static IEndpointRouteBuilder MapWithOpenApiEndpoints(this IEndpointRouteB return $"{collection.Count} {string.Join(',', collection.Select(f => f.FileName))}"; }).WithOpenApi(); + group.MapPost("/IFromBody", (OrganizationCustomExchangeRatesDto dto) => + { + return $"{dto}"; + }).WithOpenApi(); + return app; } } @@ -61,4 +67,6 @@ record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) record class Person(string FirstName, string LastName); record class Address(string Street, string City, string State, string ZipCode); + sealed record OrganizationCustomExchangeRatesDto([property: JsonRequired] CurrenciesRate[] CurrenciesRates); + sealed record CurrenciesRate([property: JsonRequired] string CurrencyFrom, [property: JsonRequired] string CurrencyTo, double Rate); }