From a2fcfeeed8c2d07bbc90dfe1bc2ade64087ad06e Mon Sep 17 00:00:00 2001 From: ItsVeryWindy Date: Tue, 3 Jun 2025 12:33:39 +0100 Subject: [PATCH] use one of behind a flag instead for nullable enums --- Directory.Build.props | 2 +- .../NewtonsoftDataContractResolver.cs | 14 +- .../ConfigureSchemaGeneratorOptions.cs | 1 + .../SwaggerGenOptionsExtensions.cs | 9 + .../PublicAPI/PublicAPI.Unshipped.txt | 3 + .../JsonSerializerDataContractResolver.cs | 2 +- .../SchemaGenerator/SchemaGenerator.cs | 61 ++++- .../SchemaGenerator/SchemaGeneratorOptions.cs | 5 + .../SwaggerGenerator/SwaggerGenerator.cs | 2 +- ...waggerRequestUri=v1.DotNet8_0.verified.txt | 231 +++++++++++++++++- ...waggerRequestUri=v1.DotNet9_0.verified.txt | 231 +++++++++++++++++- .../NewtonsoftSchemaGeneratorTests.cs | 37 +++ .../ConfigureSchemaGeneratorOptionsTests.cs | 2 +- .../JsonSerializerSchemaGeneratorTests.cs | 37 ++- test/WebSites/MvcWithNullable/Program.cs | 35 ++- 15 files changed, 640 insertions(+), 32 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index ed12cd2634..589f3ffb95 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,7 +37,7 @@ snupkg true true - 9.0.4 + 9.1.0 false diff --git a/src/Swashbuckle.AspNetCore.Newtonsoft/SchemaGenerator/NewtonsoftDataContractResolver.cs b/src/Swashbuckle.AspNetCore.Newtonsoft/SchemaGenerator/NewtonsoftDataContractResolver.cs index 6132126b1c..15cfe03bd8 100644 --- a/src/Swashbuckle.AspNetCore.Newtonsoft/SchemaGenerator/NewtonsoftDataContractResolver.cs +++ b/src/Swashbuckle.AspNetCore.Newtonsoft/SchemaGenerator/NewtonsoftDataContractResolver.cs @@ -22,11 +22,13 @@ public DataContract GetDataContractForType(Type type) jsonConverter: JsonConverterFunc); } - var jsonContract = _contractResolver.ResolveContract(effectiveType); + var jsonContract = _contractResolver.ResolveContract(type); - if (jsonContract is JsonPrimitiveContract && !jsonContract.UnderlyingType.IsEnum) + var effectiveUnderlyingType = Nullable.GetUnderlyingType(jsonContract.UnderlyingType) ?? jsonContract.UnderlyingType; + + if (jsonContract is JsonPrimitiveContract && !effectiveUnderlyingType.IsEnum) { - if (!PrimitiveTypesAndFormats.TryGetValue(jsonContract.UnderlyingType, out var primitiveTypeAndFormat)) + if (!PrimitiveTypesAndFormats.TryGetValue(effectiveUnderlyingType, out var primitiveTypeAndFormat)) { primitiveTypeAndFormat = Tuple.Create(DataType.String, (string)null); } @@ -38,16 +40,16 @@ public DataContract GetDataContractForType(Type type) jsonConverter: JsonConverterFunc); } - if (jsonContract is JsonPrimitiveContract && jsonContract.UnderlyingType.IsEnum) + if (jsonContract is JsonPrimitiveContract && effectiveUnderlyingType.IsEnum) { - var enumValues = jsonContract.UnderlyingType.GetEnumValues(); + var enumValues = effectiveUnderlyingType.GetEnumValues(); // Test to determine if the serializer will treat as string var serializeAsString = (enumValues.Length > 0) && JsonConverterFunc(enumValues.GetValue(0)).StartsWith('\"'); var primitiveTypeAndFormat = serializeAsString ? PrimitiveTypesAndFormats[typeof(string)] - : PrimitiveTypesAndFormats[jsonContract.UnderlyingType.GetEnumUnderlyingType()]; + : PrimitiveTypesAndFormats[effectiveUnderlyingType.GetEnumUnderlyingType()]; return DataContract.ForPrimitive( underlyingType: jsonContract.UnderlyingType, diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/ConfigureSchemaGeneratorOptions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/ConfigureSchemaGeneratorOptions.cs index 6017437bf7..b1c245c574 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/ConfigureSchemaGeneratorOptions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/ConfigureSchemaGeneratorOptions.cs @@ -32,6 +32,7 @@ private static void DeepCopy(SchemaGeneratorOptions source, SchemaGeneratorOptio target.DiscriminatorNameSelector = source.DiscriminatorNameSelector; target.DiscriminatorValueSelector = source.DiscriminatorValueSelector; target.UseAllOfToExtendReferenceSchemas = source.UseAllOfToExtendReferenceSchemas; + target.UseOneOfForNullableEnums = source.UseOneOfForNullableEnums; target.SupportNonNullableReferenceTypes = source.SupportNonNullableReferenceTypes; target.NonNullableReferenceTypesAsRequired = source.NonNullableReferenceTypesAsRequired; target.SchemaFilters = [.. source.SchemaFilters]; diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs index c2df6a5b4a..78908bdb33 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/DependencyInjection/SwaggerGenOptionsExtensions.cs @@ -284,6 +284,15 @@ public static void UseAllOfToExtendReferenceSchemas(this SwaggerGenOptions swagg swaggerGenOptions.SchemaGeneratorOptions.UseAllOfToExtendReferenceSchemas = true; } + /// + /// Extend enumeration schemas using the oneOf construct to allow when referenced. + /// + /// + public static void UseOneOfForNullableEnums(this SwaggerGenOptions swaggerGenOptions) + { + swaggerGenOptions.SchemaGeneratorOptions.UseOneOfForNullableEnums = true; + } + /// /// Enable detection of non nullable reference types to set Nullable flag accordingly on schema properties /// diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/PublicAPI/PublicAPI.Unshipped.txt b/src/Swashbuckle.AspNetCore.SwaggerGen/PublicAPI/PublicAPI.Unshipped.txt index e69de29bb2..6a7560bb8a 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +static Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.UseOneOfForNullableEnums(this Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenOptions swaggerGenOptions) -> void +Swashbuckle.AspNetCore.SwaggerGen.SchemaGeneratorOptions.UseOneOfForNullableEnums.get -> bool +Swashbuckle.AspNetCore.SwaggerGen.SchemaGeneratorOptions.UseOneOfForNullableEnums.set -> void diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs index 021a95c0eb..fd9d1ffaae 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs @@ -45,7 +45,7 @@ public DataContract GetDataContractForType(Type type) primitiveTypeAndFormat = PrimitiveTypesAndFormats[exampleType]; return DataContract.ForPrimitive( - underlyingType: effectiveType, + underlyingType: type, dataType: primitiveTypeAndFormat.Item1, dataFormat: primitiveTypeAndFormat.Item2, jsonConverter: (value) => JsonConverterFunc(value, type)); diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs index f483d9134e..cae08b07a4 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs @@ -44,6 +44,18 @@ private OpenApiSchema GenerateSchemaForMember( MemberInfo memberInfo, DataProperty dataProperty = null) { + if (dataProperty != null) + { + var customAttributes = memberInfo.GetInlineAndMetadataAttributes(); + + var requiredAttribute = customAttributes.OfType().FirstOrDefault(); + + if (!IsNullable(requiredAttribute, dataProperty, memberInfo)) + { + modelType = Nullable.GetUnderlyingType(modelType) ?? modelType; + } + } + var dataContract = GetDataContractFor(modelType); var schema = _generatorOptions.UseOneOfForPolymorphism && IsBaseTypeWithKnownTypesDefined(dataContract, out var knownTypesDataContracts) @@ -129,6 +141,13 @@ private OpenApiSchema GenerateSchemaForParameter( ParameterInfo parameterInfo, ApiParameterRouteInfo routeInfo) { + var customAttributes = parameterInfo.GetCustomAttributes(); + + if (customAttributes.OfType().Any()) + { + modelType = Nullable.GetUnderlyingType(modelType) ?? modelType; + } + var dataContract = GetDataContractFor(modelType); var schema = _generatorOptions.UseOneOfForPolymorphism && IsBaseTypeWithKnownTypesDefined(dataContract, out var knownTypesDataContracts) @@ -143,8 +162,6 @@ private OpenApiSchema GenerateSchemaForParameter( if (schema.Reference == null) { - var customAttributes = parameterInfo.GetCustomAttributes(); - var defaultValue = parameterInfo.HasDefaultValue ? parameterInfo.DefaultValue : customAttributes.OfType().FirstOrDefault()?.Value; @@ -154,6 +171,11 @@ private OpenApiSchema GenerateSchemaForParameter( schema.Default = GenerateDefaultValue(dataContract, modelType, defaultValue); } + if (Nullable.GetUnderlyingType(modelType) is not null) + { + schema.Nullable = true; + } + schema.ApplyValidationAttributes(customAttributes); if (routeInfo != null) { @@ -255,7 +277,7 @@ private OpenApiSchema GenerateConcreteSchema(DataContract dataContract, SchemaRe case DataType.Number: case DataType.String: { - schemaFactory = () => CreatePrimitiveSchema(dataContract); + schemaFactory = () => CreatePrimitiveSchema(dataContract, schemaRepository); returnAsReference = dataContract.UnderlyingType.IsEnum && !_generatorOptions.UseInlineDefinitionsForEnums; break; } @@ -301,16 +323,41 @@ private bool TryGetCustomTypeMapping(Type modelType, out Func sch (modelType.IsConstructedGenericType && _generatorOptions.CustomTypeMappings.TryGetValue(modelType.GetGenericTypeDefinition(), out schemaFactory)); } - private static OpenApiSchema CreatePrimitiveSchema(DataContract dataContract) + private OpenApiSchema CreatePrimitiveSchema(DataContract dataContract, SchemaRepository schemaRepository) { + var underlyingType = Nullable.GetUnderlyingType(dataContract.UnderlyingType) ?? dataContract.UnderlyingType; + + if (underlyingType.IsEnum && dataContract.UnderlyingType != underlyingType) + { + var enumDataContract = GetDataContractFor(underlyingType); + + var enumSchema = GenerateConcreteSchema(enumDataContract, schemaRepository); + + if (_generatorOptions.UseInlineDefinitionsForEnums) + { + enumSchema.Enum.Add(null); + return enumSchema; + } + + if (_generatorOptions.UseOneOfForNullableEnums) + { + enumSchema.OneOf = + [ + new OpenApiSchema { Reference = enumSchema.Reference }, + new OpenApiSchema { Enum = [null] } + ]; + enumSchema.Reference = null; + } + + return enumSchema; + } + var schema = new OpenApiSchema { Type = FromDataType(dataContract.DataType), Format = dataContract.DataFormat }; - var underlyingType = dataContract.UnderlyingType; - if (underlyingType.IsEnum) { var enumValues = underlyingType.GetEnumValues().Cast(); @@ -422,7 +469,7 @@ private OpenApiSchema CreateObjectSchema(DataContract dataContract, SchemaReposi continue; } - var memberType = dataProperty.MemberType; + var memberType = dataProperty.IsNullable ? dataProperty.MemberType : (Nullable.GetUnderlyingType(dataProperty.MemberType) ?? dataProperty.MemberType); schema.Properties[dataProperty.Name] = (dataProperty.MemberInfo != null) ? GenerateSchemaForMember(memberType, schemaRepository, dataProperty.MemberInfo, dataProperty) diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGeneratorOptions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGeneratorOptions.cs index dd12f4dba3..5eee173030 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGeneratorOptions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGeneratorOptions.cs @@ -40,6 +40,11 @@ public SchemaGeneratorOptions() public IList SchemaFilters { get; set; } + /// + /// Gets or sets a value indicating whether to extend enumeration schemas using the oneOf construct to allow when referenced. + /// + public bool UseOneOfForNullableEnums { get; set; } + private string DefaultSchemaIdSelector(Type modelType) { if (!modelType.IsConstructedGenericType) diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs index 55c44f9c3d..22158715ab 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs @@ -586,7 +586,7 @@ apiParameter.Type is not null && var schema = (type != null) ? GenerateSchema( - type, + Nullable.GetUnderlyingType(type) ?? type, schemaRepository, apiParameter.PropertyInfo(), apiParameter.ParameterInfo(), diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet8_0.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet8_0.verified.txt index ac23e23151..653a890f7d 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet8_0.verified.txt +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet8_0.verified.txt @@ -5,7 +5,7 @@ "version": "1.0" }, "paths": { - "/api/Enum": { + "/api/Enum/query": { "get": { "tags": [ "Enum" @@ -31,15 +31,15 @@ } } }, - "/api/RequiredEnum": { + "/api/Enum/path/{logLevel}": { "get": { "tags": [ - "RequiredEnum" + "Enum" ], "parameters": [ { "name": "logLevel", - "in": "query", + "in": "path", "required": true, "schema": { "allOf": [ @@ -57,6 +57,186 @@ } } } + }, + "/api/Enum/header": { + "get": { + "tags": [ + "Enum" + ], + "parameters": [ + { + "name": "logLevel", + "in": "header", + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Enum/enum-body": { + "get": { + "tags": [ + "Enum" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "default": 4, + "nullable": true + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "default": 4, + "nullable": true + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "default": 4, + "nullable": true + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Enum/type-body": { + "get": { + "tags": [ + "Enum" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeWithNullable" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeWithNullable" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeWithNullable" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/RequiredEnum": { + "get": { + "tags": [ + "RequiredEnum" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -73,6 +253,49 @@ ], "type": "integer", "format": "int32" + }, + "TypeWithNullable": { + "required": [ + "jsonRequiredLogLevel", + "requiredLogLevel" + ], + "type": "object", + "properties": { + "logLevel": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "nullable": true + }, + "requiredLogLevel": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ] + }, + "jsonRequiredLogLevel": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "nullable": true + } + }, + "additionalProperties": false } } } diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet9_0.verified.txt b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet9_0.verified.txt index ac23e23151..653a890f7d 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet9_0.verified.txt +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet9_0.verified.txt @@ -5,7 +5,7 @@ "version": "1.0" }, "paths": { - "/api/Enum": { + "/api/Enum/query": { "get": { "tags": [ "Enum" @@ -31,15 +31,15 @@ } } }, - "/api/RequiredEnum": { + "/api/Enum/path/{logLevel}": { "get": { "tags": [ - "RequiredEnum" + "Enum" ], "parameters": [ { "name": "logLevel", - "in": "query", + "in": "path", "required": true, "schema": { "allOf": [ @@ -57,6 +57,186 @@ } } } + }, + "/api/Enum/header": { + "get": { + "tags": [ + "Enum" + ], + "parameters": [ + { + "name": "logLevel", + "in": "header", + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Enum/enum-body": { + "get": { + "tags": [ + "Enum" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "default": 4, + "nullable": true + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "default": 4, + "nullable": true + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "default": 4, + "nullable": true + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Enum/type-body": { + "get": { + "tags": [ + "Enum" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeWithNullable" + } + ] + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeWithNullable" + } + ] + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/TypeWithNullable" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/RequiredEnum": { + "get": { + "tags": [ + "RequiredEnum" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + }, + "text/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + }, + "application/*+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ], + "default": 4 + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -73,6 +253,49 @@ ], "type": "integer", "format": "int32" + }, + "TypeWithNullable": { + "required": [ + "jsonRequiredLogLevel", + "requiredLogLevel" + ], + "type": "object", + "properties": { + "logLevel": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "nullable": true + }, + "requiredLogLevel": { + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ] + }, + "jsonRequiredLogLevel": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevel" + }, + { + "enum": [ + null + ] + } + ], + "nullable": true + } + }, + "additionalProperties": false } } } diff --git a/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs index dbe50b446b..2326529d00 100644 --- a/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.Newtonsoft.Test/SchemaGenerator/NewtonsoftSchemaGeneratorTests.cs @@ -298,6 +298,26 @@ public void GenerateSchema_DoesNotSetNullableFlag_IfReferencedEnum() Assert.Equal("IntEnum", schema.Properties[propertyName].Reference.Id); } + [Fact] + public void GenerateSchema_DoesSetNullableFlag_IfReferencedEnumAndOneOfEnabled() + { + var schemaRepository = new SchemaRepository(); + + var referenceSchema = Subject(o => o.UseOneOfForNullableEnums = true).GenerateSchema(typeof(TypeWithNullableProperties), schemaRepository); + + var schema = schemaRepository.Schemas[referenceSchema.Reference.Id]; + const string propertyName = nameof(TypeWithNullableProperties.NullableIntEnumProperty); + Assert.True(schema.Properties[propertyName].Nullable); + Assert.Null(schema.Properties[propertyName].Reference); + Assert.NotNull(schema.Properties[propertyName].OneOf); + Assert.Equal(2, schema.Properties[propertyName].OneOf.Count); + Assert.NotNull(schema.Properties[propertyName].OneOf[0].Reference); + Assert.Equal("IntEnum", schema.Properties[propertyName].OneOf[0].Reference.Id); + Assert.NotNull(schema.Properties[propertyName].OneOf[1].Enum); + Assert.Single(schema.Properties[propertyName].OneOf[1].Enum); + Assert.Null(schema.Properties[propertyName].OneOf[1].Enum[0]); + } + [Fact] public void GenerateSchema_SetNullableFlag_IfInlineEnum() { @@ -309,6 +329,23 @@ public void GenerateSchema_SetNullableFlag_IfInlineEnum() Assert.True(schema.Properties[nameof(TypeWithNullableProperties.NullableIntEnumProperty)].Nullable); } + [Fact] + public void GenerateSchema_AddNullEnumValue_IfInlineEnum() + { + var expectedEnumAsJson = new string[] { "2", "4", "8", null }; + + var schemaRepository = new SchemaRepository(); + + var referenceSchema = Subject(o => o.UseInlineDefinitionsForEnums = true).GenerateSchema(typeof(TypeWithNullableProperties), schemaRepository); + + Assert.Contains(referenceSchema.Reference.Id, schemaRepository.Schemas); + var schema = schemaRepository.Schemas[referenceSchema.Reference.Id]; + Assert.Contains(nameof(TypeWithNullableProperties.NullableIntEnumProperty), schema.Properties); + Assert.NotNull(schema.Properties[nameof(TypeWithNullableProperties.NullableIntEnumProperty)].Enum); + Assert.Equal(expectedEnumAsJson, schema.Properties[nameof(TypeWithNullableProperties.NullableIntEnumProperty)].Enum.Select(openApiAny => openApiAny?.ToJson())); + + } + [Theory] [InlineData(typeof(TypeWithDefaultAttributes), nameof(TypeWithDefaultAttributes.BoolWithDefault), "true")] [InlineData(typeof(TypeWithDefaultAttributes), nameof(TypeWithDefaultAttributes.IntWithDefault), "2147483647")] diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/ConfigureSchemaGeneratorOptionsTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/ConfigureSchemaGeneratorOptionsTests.cs index 8db1f83063..6a60c3b81d 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/ConfigureSchemaGeneratorOptionsTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/ConfigureSchemaGeneratorOptionsTests.cs @@ -16,7 +16,7 @@ public static void DeepCopy_Copies_All_Properties() // If this assertion fails, it means that a new property has been added // to SwaggerGeneratorOptions and ConfigureSchemaGeneratorOptions.DeepCopy() needs to be updated - Assert.Equal(13, publicProperties.Length); + Assert.Equal(14, publicProperties.Length); } [Fact] diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index e5020c6fa3..e6391c0e21 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -86,12 +86,13 @@ public void GenerateSchema_GeneratesPrimitiveSchema_IfPrimitiveOrNullablePrimiti } [Theory] - [InlineData(typeof(IntEnum), "int32", false, "2", "4", "8")] - [InlineData(typeof(LongEnum), "int64", false, "2", "4", "8")] + [InlineData(typeof(IntEnum), "int32", "2", "4", "8")] + [InlineData(typeof(LongEnum), "int64", "2", "4", "8")] + [InlineData(typeof(IntEnum?), "int32", "2", "4", "8")] + [InlineData(typeof(LongEnum?), "int64", "2", "4", "8")] public void GenerateSchema_GeneratesReferencedEnumSchema_IfEnumOrNullableEnumType( Type type, string expectedFormat, - bool expectedNullable, params string[] expectedEnumAsJson) { var schemaRepository = new SchemaRepository(); @@ -102,7 +103,35 @@ public void GenerateSchema_GeneratesReferencedEnumSchema_IfEnumOrNullableEnumTyp var schema = schemaRepository.Schemas[referenceSchema.Reference.Id]; Assert.Equal(JsonSchemaTypes.Integer, schema.Type); Assert.Equal(expectedFormat, schema.Format); - Assert.Equal(expectedNullable, schema.Nullable); + Assert.False(schema.Nullable); + Assert.NotNull(schema.Enum); + Assert.Equal(expectedEnumAsJson, schema.Enum.Select(openApiAny => openApiAny.ToJson())); + } + + [Theory] + [InlineData(typeof(IntEnum?), "int32", "2", "4", "8")] + [InlineData(typeof(LongEnum?), "int64", "2", "4", "8")] + public void GenerateSchema_GeneratesReferencedEnumSchemaWithOneOf_IfEnumOrNullableEnumTypeAndOneOfEnabled( + Type type, + string expectedFormat, + params string[] expectedEnumAsJson) + { + var schemaRepository = new SchemaRepository(); + + var referenceSchema = Subject(o => o.UseOneOfForNullableEnums = true).GenerateSchema(type, schemaRepository); + + Assert.Null(referenceSchema.Reference); + Assert.True(referenceSchema.Nullable); + Assert.NotNull(referenceSchema.OneOf); + Assert.Equal(2, referenceSchema.OneOf.Count); + Assert.NotNull(referenceSchema.OneOf[1].Enum); + Assert.Single(referenceSchema.OneOf[1].Enum); + Assert.Null(referenceSchema.OneOf[1].Enum[0]); + + var schema = schemaRepository.Schemas[referenceSchema.OneOf[0].Reference.Id]; + Assert.Equal(JsonSchemaTypes.Integer, schema.Type); + Assert.Equal(expectedFormat, schema.Format); + Assert.False(schema.Nullable); Assert.NotNull(schema.Enum); Assert.Equal(expectedEnumAsJson, schema.Enum.Select(openApiAny => openApiAny.ToJson())); } diff --git a/test/WebSites/MvcWithNullable/Program.cs b/test/WebSites/MvcWithNullable/Program.cs index 0ea2627d83..68238461f4 100644 --- a/test/WebSites/MvcWithNullable/Program.cs +++ b/test/WebSites/MvcWithNullable/Program.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); @@ -8,6 +9,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { + options.UseOneOfForNullableEnums(); options.UseAllOfToExtendReferenceSchemas(); }); @@ -28,9 +30,25 @@ [Route("api/[controller]")] public class EnumController : ControllerBase { - [HttpGet] - public IActionResult Get(LogLevel? logLevel = LogLevel.Error) + [HttpGet("query")] + public IActionResult GetQuery(LogLevel? logLevel = LogLevel.Error) + => Ok(new { logLevel }); + + [HttpGet("path/{logLevel}")] + public IActionResult GetPath(LogLevel? logLevel = LogLevel.Error) + => Ok(new { logLevel }); + + [HttpGet("header")] + public IActionResult GetHeader([FromHeader] LogLevel? logLevel = LogLevel.Error) + => Ok(new { logLevel }); + + [HttpGet("enum-body")] + public IActionResult GetEnumBody([FromBody] LogLevel? logLevel = LogLevel.Error) => Ok(new { logLevel }); + + [HttpGet("type-body")] + public IActionResult GetTypeBody([FromBody] TypeWithNullable typeWithNullable) + => Ok(new { typeWithNullable.LogLevel }); } [ApiController] @@ -38,10 +56,21 @@ public IActionResult Get(LogLevel? logLevel = LogLevel.Error) public class RequiredEnumController : ControllerBase { [HttpGet] - public IActionResult Get([Required] LogLevel? logLevel = LogLevel.Error) + public IActionResult Get([FromBody, Required] LogLevel? logLevel = LogLevel.Error) => Ok(new { logLevel }); } +public class TypeWithNullable +{ + public LogLevel? LogLevel { get; set; } = Microsoft.Extensions.Logging.LogLevel.Error; + + [Required] + public LogLevel? RequiredLogLevel { get; set; } = Microsoft.Extensions.Logging.LogLevel.Error; + + [JsonRequired] + public LogLevel? JsonRequiredLogLevel { get; set; } = Microsoft.Extensions.Logging.LogLevel.Error; +} + namespace MvcWithNullable { ///