@@ -21,6 +21,8 @@ namespace Microsoft.OpenApi
2121 /// </summary>
2222 public class OpenApiSchema : IOpenApiExtensible , IOpenApiSchema , IOpenApiSchemaMissingProperties , IOpenApiSchemaWithUnevaluatedProperties , IMetadataContainer
2323 {
24+ private static readonly IEnumerable < JsonNode > s_singleNullElementList = [ JsonNullSentinel . JsonNull ] ;
25+
2426 /// <inheritdoc />
2527 public string ? Title { get ; set ; }
2628
@@ -109,13 +111,8 @@ public string? ExclusiveMinimum
109111 /// <inheritdoc />
110112 public JsonSchemaType ? Type { get ; set ; }
111113
112- // x-nullable is filtered out by deserializers, but keep the check here in case it gets added from user code.
113- private bool IsNullable =>
114- ( Type . HasValue && Type . Value . HasFlag ( JsonSchemaType . Null ) ) ||
115- Extensions is not null &&
116- Extensions . TryGetValue ( OpenApiConstants . NullableExtension , out var nullExtRawValue ) &&
117- nullExtRawValue is JsonNodeExtension { Node : JsonNode jsonNode } &&
118- jsonNode . GetValueKind ( ) is JsonValueKind . True ;
114+ private bool HasNullType
115+ => Type . HasValue && Type . Value . HasFlag ( JsonSchemaType . Null ) ;
119116
120117 /// <inheritdoc />
121118 public string ? Const { get ; set ; }
@@ -517,57 +514,30 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
517514 : Enum ;
518515 writer . WriteOptionalCollection ( OpenApiConstants . Enum , enumValue , ( nodeWriter , s ) => nodeWriter . WriteAny ( s ) ) ;
519516
520- // Handle oneOf/anyOf with null type for v3.0 downcast
521- IList < IOpenApiSchema > ? effectiveOneOf = OneOf ;
522- IList < IOpenApiSchema > ? effectiveAnyOf = AnyOf ;
523- bool hasNullInComposition = false ;
524- bool hasOneOfNullAndSingleEnumWith3_0 = false ;
525- JsonSchemaType ? inferredType = null ;
526-
527517 if ( version == OpenApiSpecVersion . OpenApi3_0 )
528518 {
529- ( effectiveOneOf , var inferredOneOf , var nullInOneOf ) = ProcessCompositionForNull ( OneOf ) ;
530- hasNullInComposition |= nullInOneOf ;
531- inferredType = inferredOneOf ?? inferredType ;
532- ( effectiveAnyOf , var inferredAnyOf , var nullInAnyOf ) = ProcessCompositionForNull ( AnyOf ) ;
533- hasNullInComposition |= nullInAnyOf ;
534- inferredType = inferredAnyOf ?? inferredType ;
535-
536- hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count : 1 } &&
537- effectiveOneOf [ 0 ] . Enum is { Count : > 0 } ;
519+ // If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value.
520+ if ( Type == JsonSchemaType . Null &&
521+ OneOf is not { Count : > 0 } &&
522+ AnyOf is not { Count : > 0 } &&
523+ AllOf is not { Count : > 0 } &&
524+ Enum is not { Count : > 0 } )
525+ {
526+ writer . WriteOptionalCollection ( OpenApiConstants . Enum , s_singleNullElementList , ( nodeWriter , s ) => nodeWriter . WriteAny ( s ) ) ;
527+ }
538528 }
539529
540530 // type
541- SerializeTypeProperty ( writer , version , inferredType ) ;
531+ var serializedTypeProperty = TrySerializeTypeProperty ( writer , version ) ;
542532
543533 // allOf
544534 writer . WriteOptionalCollection ( OpenApiConstants . AllOf , AllOf , callback ) ;
545535
546536 // anyOf
547- writer . WriteOptionalCollection ( OpenApiConstants . AnyOf , effectiveAnyOf , callback ) ;
537+ writer . WriteOptionalCollection ( OpenApiConstants . AnyOf , AnyOf , callback ) ;
548538
549539 // oneOf
550- if ( hasOneOfNullAndSingleEnumWith3_0 )
551- {
552- writer . WriteRequiredCollection ( OpenApiConstants . OneOf , effectiveOneOf ! , ( writer , element ) =>
553- {
554- var clonedToMutateEnum = element . CreateShallowCopy ( ) ;
555- if ( clonedToMutateEnum is OpenApiSchema { Enum : { } existingEnum } concreteCloned )
556- {
557- concreteCloned . Enum = [ .. existingEnum , JsonNullSentinel . JsonNull ] ;
558- callback ( writer , clonedToMutateEnum ) ;
559- }
560- else
561- {
562- callback ( writer , element ) ;
563- }
564- } ) ;
565- }
566- else
567- {
568- writer . WriteOptionalCollection ( OpenApiConstants . OneOf , effectiveOneOf , callback ) ;
569- }
570-
540+ writer . WriteOptionalCollection ( OpenApiConstants . OneOf , OneOf , callback ) ;
571541
572542 // not
573543 writer . WriteOptionalObject ( OpenApiConstants . Not , Not , callback ) ;
@@ -603,9 +573,14 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
603573 writer . WriteOptionalObject ( OpenApiConstants . Default , Default , ( w , d ) => w . WriteAny ( d ) ) ;
604574
605575 // nullable
606- if ( version == OpenApiSpecVersion . OpenApi3_0 )
576+ if ( version == OpenApiSpecVersion . OpenApi3_0 && serializedTypeProperty )
607577 {
608- SerializeNullable ( writer , version , hasNullInComposition ) ;
578+ // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
579+ // This keyword only takes effect if type is explicitly defined within the same Schema Object.
580+ //
581+ // If the user explicitly set IsNullable to true, we serialize it even if redundant.
582+ // But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant.
583+ SerializeNullable ( writer , version ) ;
609584 }
610585
611586 // discriminator
@@ -832,7 +807,7 @@ private void SerializeAsV2(
832807 writer . WriteStartObject ( ) ;
833808
834809 // type
835- SerializeTypeProperty ( writer , OpenApiSpecVersion . OpenApi2_0 ) ;
810+ TrySerializeTypeProperty ( writer , OpenApiSpecVersion . OpenApi2_0 ) ;
836811
837812 // description
838813 writer . WriteProperty ( OpenApiConstants . Description , Description ) ;
@@ -995,31 +970,32 @@ private void SerializeAsV2(
995970 writer . WriteEndObject ( ) ;
996971 }
997972
998- private void SerializeTypeProperty ( IOpenApiWriter writer , OpenApiSpecVersion version , JsonSchemaType ? inferredType = null )
973+ private bool TrySerializeTypeProperty ( IOpenApiWriter writer , OpenApiSpecVersion version , JsonSchemaType ? inferredType = null )
999974 {
1000975 // Use original type or inferred type when the explicit type is not set
1001976 var typeToUse = Type ?? inferredType ;
1002977
1003978 if ( typeToUse is null )
1004979 {
1005- return ;
980+ return false ;
1006981 }
1007982
1008- var unifiedType = IsNullable ? typeToUse . Value | JsonSchemaType . Null : typeToUse . Value ;
1009- var typeWithoutNull = unifiedType & ~ JsonSchemaType . Null ;
1010-
1011983 switch ( version )
1012984 {
1013985 case OpenApiSpecVersion . OpenApi2_0 or OpenApiSpecVersion . OpenApi3_0 :
986+ var typeWithoutNull = typeToUse . Value & ~ JsonSchemaType . Null ;
1014987 if ( typeWithoutNull != 0 && ! HasMultipleTypes ( typeWithoutNull ) )
1015988 {
1016989 writer . WriteProperty ( OpenApiConstants . Type , typeWithoutNull . ToFirstIdentifier ( ) ) ;
990+ return true ;
1017991 }
1018992 break ;
1019993 default :
1020- WriteUnifiedSchemaType ( unifiedType , writer ) ;
1021- break ;
994+ WriteUnifiedSchemaType ( typeToUse . Value , writer ) ;
995+ return true ;
1022996 }
997+
998+ return false ;
1023999 }
10241000
10251001 private static bool IsPowerOfTwo ( int x )
@@ -1054,9 +1030,9 @@ where type.HasFlag(flag)
10541030 }
10551031 }
10561032
1057- private void SerializeNullable ( IOpenApiWriter writer , OpenApiSpecVersion version , bool hasNullInComposition = false )
1033+ private void SerializeNullable ( IOpenApiWriter writer , OpenApiSpecVersion version )
10581034 {
1059- if ( IsNullable || hasNullInComposition )
1035+ if ( HasNullType )
10601036 {
10611037 switch ( version )
10621038 {
@@ -1070,63 +1046,6 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
10701046 }
10711047 }
10721048
1073- /// <summary>
1074- /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type.
1075- /// </summary>
1076- /// <param name="composition">The list of schemas in the composition.</param>
1077- /// <returns>A tuple with the effective list, inferred type, and whether null is present in composition.</returns>
1078- private static ( IList < IOpenApiSchema > ? effective , JsonSchemaType ? inferredType , bool hasNullInComposition )
1079- ProcessCompositionForNull ( IList < IOpenApiSchema > ? composition )
1080- {
1081- if ( composition is null || ! composition . Any ( static s => s . Type is JsonSchemaType . Null ) )
1082- {
1083- // Nothing to patch
1084- return ( composition , null , false ) ;
1085- }
1086-
1087- var nonNullSchemas = composition
1088- . Where ( static s => s . Type is null or not JsonSchemaType . Null )
1089- . ToList ( ) ;
1090-
1091- if ( nonNullSchemas . Count > 0 )
1092- {
1093- JsonSchemaType commonType = 0 ;
1094-
1095- foreach ( var schema in nonNullSchemas )
1096- {
1097- if ( schema . Type . HasValue )
1098- {
1099- commonType |= schema . Type . Value & ~ JsonSchemaType . Null ;
1100- }
1101- else if ( schema . Enum is { Count : > 0 } )
1102- {
1103- foreach ( var enumValue in schema . Enum . Where ( x => x is not null ) )
1104- {
1105- var currentType = enumValue . GetValueKind ( ) switch
1106- {
1107- JsonValueKind . Array => JsonSchemaType . Array ,
1108- JsonValueKind . String => JsonSchemaType . String ,
1109- JsonValueKind . Number => JsonSchemaType . Number ,
1110- JsonValueKind . True or JsonValueKind . False => JsonSchemaType . Boolean ,
1111- JsonValueKind . Null => ( JsonSchemaType ) 0 ,
1112- _ => JsonSchemaType . Object ,
1113- } ;
1114-
1115- commonType |= currentType ;
1116- }
1117-
1118- commonType |= JsonSchemaType . String ;
1119- }
1120- }
1121-
1122- return ( nonNullSchemas , commonType == 0 ? null : commonType , true ) ;
1123- }
1124- else
1125- {
1126- return ( null , null , true ) ;
1127- }
1128- }
1129-
11301049#if NET5_0_OR_GREATER
11311050 private static readonly Array jsonSchemaTypeValues = System . Enum . GetValues < JsonSchemaType > ( ) ;
11321051#else
0 commit comments