@@ -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 internal bool WasConstExplicitlySet { get ; private set ; }
121118
@@ -538,57 +535,30 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
538535 : Enum ;
539536 writer . WriteOptionalCollection ( OpenApiConstants . Enum , enumValue , ( nodeWriter , s ) => nodeWriter . WriteAny ( s ) ) ;
540537
541- // Handle oneOf/anyOf with null type for v3.0 downcast
542- IList < IOpenApiSchema > ? effectiveOneOf = OneOf ;
543- IList < IOpenApiSchema > ? effectiveAnyOf = AnyOf ;
544- bool hasNullInComposition = false ;
545- bool hasOneOfNullAndSingleEnumWith3_0 = false ;
546- JsonSchemaType ? inferredType = null ;
547-
548538 if ( version == OpenApiSpecVersion . OpenApi3_0 )
549539 {
550- ( effectiveOneOf , var inferredOneOf , var nullInOneOf ) = ProcessCompositionForNull ( OneOf ) ;
551- hasNullInComposition |= nullInOneOf ;
552- inferredType = inferredOneOf ?? inferredType ;
553- ( effectiveAnyOf , var inferredAnyOf , var nullInAnyOf ) = ProcessCompositionForNull ( AnyOf ) ;
554- hasNullInComposition |= nullInAnyOf ;
555- inferredType = inferredAnyOf ?? inferredType ;
556-
557- hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count : 1 } &&
558- effectiveOneOf [ 0 ] . Enum is { Count : > 0 } ;
540+ // If we have a schema that's only just { "type": "null" }, we serialize it as enum with null value.
541+ if ( Type == JsonSchemaType . Null &&
542+ OneOf is not { Count : > 0 } &&
543+ AnyOf is not { Count : > 0 } &&
544+ AllOf is not { Count : > 0 } &&
545+ Enum is not { Count : > 0 } )
546+ {
547+ writer . WriteOptionalCollection ( OpenApiConstants . Enum , s_singleNullElementList , ( nodeWriter , s ) => nodeWriter . WriteAny ( s ) ) ;
548+ }
559549 }
560550
561551 // type
562- SerializeTypeProperty ( writer , version , inferredType ) ;
552+ var serializedTypeProperty = TrySerializeTypeProperty ( writer , version ) ;
563553
564554 // allOf
565555 writer . WriteOptionalCollection ( OpenApiConstants . AllOf , AllOf , callback ) ;
566556
567557 // anyOf
568- writer . WriteOptionalCollection ( OpenApiConstants . AnyOf , effectiveAnyOf , callback ) ;
558+ writer . WriteOptionalCollection ( OpenApiConstants . AnyOf , AnyOf , callback ) ;
569559
570560 // oneOf
571- if ( hasOneOfNullAndSingleEnumWith3_0 )
572- {
573- writer . WriteRequiredCollection ( OpenApiConstants . OneOf , effectiveOneOf ! , ( writer , element ) =>
574- {
575- var clonedToMutateEnum = element . CreateShallowCopy ( ) ;
576- if ( clonedToMutateEnum is OpenApiSchema { Enum : { } existingEnum } concreteCloned )
577- {
578- concreteCloned . Enum = [ .. existingEnum , JsonNullSentinel . JsonNull ] ;
579- callback ( writer , clonedToMutateEnum ) ;
580- }
581- else
582- {
583- callback ( writer , element ) ;
584- }
585- } ) ;
586- }
587- else
588- {
589- writer . WriteOptionalCollection ( OpenApiConstants . OneOf , effectiveOneOf , callback ) ;
590- }
591-
561+ writer . WriteOptionalCollection ( OpenApiConstants . OneOf , OneOf , callback ) ;
592562
593563 // not
594564 writer . WriteOptionalObject ( OpenApiConstants . Not , Not , callback ) ;
@@ -624,9 +594,14 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
624594 writer . WriteOptionalObject ( OpenApiConstants . Default , Default , ( w , d ) => w . WriteAny ( d ) ) ;
625595
626596 // nullable
627- if ( version == OpenApiSpecVersion . OpenApi3_0 )
597+ if ( version == OpenApiSpecVersion . OpenApi3_0 && serializedTypeProperty )
628598 {
629- SerializeNullable ( writer , version , hasNullInComposition ) ;
599+ // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
600+ // This keyword only takes effect if type is explicitly defined within the same Schema Object.
601+ //
602+ // If the user explicitly set IsNullable to true, we serialize it even if redundant.
603+ // But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant.
604+ SerializeNullable ( writer , version ) ;
630605 }
631606
632607 // discriminator
@@ -858,7 +833,7 @@ private void SerializeAsV2(
858833 writer . WriteStartObject ( ) ;
859834
860835 // type
861- SerializeTypeProperty ( writer , OpenApiSpecVersion . OpenApi2_0 ) ;
836+ TrySerializeTypeProperty ( writer , OpenApiSpecVersion . OpenApi2_0 ) ;
862837
863838 // description
864839 writer . WriteProperty ( OpenApiConstants . Description , Description ) ;
@@ -1021,31 +996,32 @@ private void SerializeAsV2(
1021996 writer . WriteEndObject ( ) ;
1022997 }
1023998
1024- private void SerializeTypeProperty ( IOpenApiWriter writer , OpenApiSpecVersion version , JsonSchemaType ? inferredType = null )
999+ private bool TrySerializeTypeProperty ( IOpenApiWriter writer , OpenApiSpecVersion version , JsonSchemaType ? inferredType = null )
10251000 {
10261001 // Use original type or inferred type when the explicit type is not set
10271002 var typeToUse = Type ?? inferredType ;
10281003
10291004 if ( typeToUse is null )
10301005 {
1031- return ;
1006+ return false ;
10321007 }
10331008
1034- var unifiedType = IsNullable ? typeToUse . Value | JsonSchemaType . Null : typeToUse . Value ;
1035- var typeWithoutNull = unifiedType & ~ JsonSchemaType . Null ;
1036-
10371009 switch ( version )
10381010 {
10391011 case OpenApiSpecVersion . OpenApi2_0 or OpenApiSpecVersion . OpenApi3_0 :
1012+ var typeWithoutNull = typeToUse . Value & ~ JsonSchemaType . Null ;
10401013 if ( typeWithoutNull != 0 && ! HasMultipleTypes ( typeWithoutNull ) )
10411014 {
10421015 writer . WriteProperty ( OpenApiConstants . Type , typeWithoutNull . ToFirstIdentifier ( ) ) ;
1016+ return true ;
10431017 }
10441018 break ;
10451019 default :
1046- WriteUnifiedSchemaType ( unifiedType , writer ) ;
1047- break ;
1020+ WriteUnifiedSchemaType ( typeToUse . Value , writer ) ;
1021+ return true ;
10481022 }
1023+
1024+ return false ;
10491025 }
10501026
10511027 private static bool IsPowerOfTwo ( int x )
@@ -1080,9 +1056,9 @@ where type.HasFlag(flag)
10801056 }
10811057 }
10821058
1083- private void SerializeNullable ( IOpenApiWriter writer , OpenApiSpecVersion version , bool hasNullInComposition = false )
1059+ private void SerializeNullable ( IOpenApiWriter writer , OpenApiSpecVersion version )
10841060 {
1085- if ( IsNullable || hasNullInComposition )
1061+ if ( HasNullType )
10861062 {
10871063 switch ( version )
10881064 {
@@ -1096,63 +1072,6 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
10961072 }
10971073 }
10981074
1099- /// <summary>
1100- /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type.
1101- /// </summary>
1102- /// <param name="composition">The list of schemas in the composition.</param>
1103- /// <returns>A tuple with the effective list, inferred type, and whether null is present in composition.</returns>
1104- private static ( IList < IOpenApiSchema > ? effective , JsonSchemaType ? inferredType , bool hasNullInComposition )
1105- ProcessCompositionForNull ( IList < IOpenApiSchema > ? composition )
1106- {
1107- if ( composition is null || ! composition . Any ( static s => s . Type is JsonSchemaType . Null ) )
1108- {
1109- // Nothing to patch
1110- return ( composition , null , false ) ;
1111- }
1112-
1113- var nonNullSchemas = composition
1114- . Where ( static s => s . Type is null or not JsonSchemaType . Null )
1115- . ToList ( ) ;
1116-
1117- if ( nonNullSchemas . Count > 0 )
1118- {
1119- JsonSchemaType commonType = 0 ;
1120-
1121- foreach ( var schema in nonNullSchemas )
1122- {
1123- if ( schema . Type . HasValue )
1124- {
1125- commonType |= schema . Type . Value & ~ JsonSchemaType . Null ;
1126- }
1127- else if ( schema . Enum is { Count : > 0 } )
1128- {
1129- foreach ( var enumValue in schema . Enum . Where ( x => x is not null ) )
1130- {
1131- var currentType = enumValue . GetValueKind ( ) switch
1132- {
1133- JsonValueKind . Array => JsonSchemaType . Array ,
1134- JsonValueKind . String => JsonSchemaType . String ,
1135- JsonValueKind . Number => JsonSchemaType . Number ,
1136- JsonValueKind . True or JsonValueKind . False => JsonSchemaType . Boolean ,
1137- JsonValueKind . Null => ( JsonSchemaType ) 0 ,
1138- _ => JsonSchemaType . Object ,
1139- } ;
1140-
1141- commonType |= currentType ;
1142- }
1143-
1144- commonType |= JsonSchemaType . String ;
1145- }
1146- }
1147-
1148- return ( nonNullSchemas , commonType == 0 ? null : commonType , true ) ;
1149- }
1150- else
1151- {
1152- return ( null , null , true ) ;
1153- }
1154- }
1155-
11561075#if NET5_0_OR_GREATER
11571076 private static readonly Array jsonSchemaTypeValues = System . Enum . GetValues < JsonSchemaType > ( ) ;
11581077#else
0 commit comments