@@ -21,6 +21,8 @@ namespace Microsoft.OpenApi
2121 /// </summary>
2222 public class OpenApiSchema : IOpenApiExtensible , IOpenApiSchema , IOpenApiSchemaMissingProperties , IOpenApiSchemaWithUnevaluatedProperties , IMetadataContainer
2323 {
24+ private static readonly List < JsonNode > s_singleNullElementList = [ JsonNullSentinel . JsonNull ] ;
25+
2426 /// <inheritdoc />
2527 public string ? Title { get ; set ; }
2628
@@ -110,12 +112,17 @@ public string? ExclusiveMinimum
110112 public JsonSchemaType ? Type { get ; set ; }
111113
112114 // 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 ;
115+ internal bool IsNullable
116+ {
117+ get => field || HasNullableExtension || ( Type . HasValue && Type . Value . HasFlag ( JsonSchemaType . Null ) ) ;
118+ set => field = value ;
119+ }
120+
121+ private bool HasNullableExtension
122+ => Extensions is not null &&
123+ Extensions . TryGetValue ( OpenApiConstants . NullableExtension , out var nullExtRawValue ) &&
124+ nullExtRawValue is JsonNodeExtension { Node : JsonNode jsonNode } &&
125+ jsonNode . GetValueKind ( ) is JsonValueKind . True ;
119126
120127 /// <inheritdoc />
121128 public string ? Const { get ; set ; }
@@ -518,56 +525,36 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
518525 writer . WriteOptionalCollection ( OpenApiConstants . Enum , enumValue , ( nodeWriter , s ) => nodeWriter . WriteAny ( s ) ) ;
519526
520527 // Handle oneOf/anyOf with null type for v3.0 downcast
521- IList < IOpenApiSchema > ? effectiveOneOf = OneOf ;
522- IList < IOpenApiSchema > ? effectiveAnyOf = AnyOf ;
523528 bool hasNullInComposition = false ;
524- bool hasOneOfNullAndSingleEnumWith3_0 = false ;
525529 JsonSchemaType ? inferredType = null ;
526530
527531 if ( version == OpenApiSpecVersion . OpenApi3_0 )
528532 {
529- ( effectiveOneOf , var inferredOneOf , var nullInOneOf ) = ProcessCompositionForNull ( OneOf ) ;
533+ ( var inferredOneOf , var nullInOneOf ) = ProcessCompositionForNull ( OneOf ) ;
530534 hasNullInComposition |= nullInOneOf ;
531535 inferredType = inferredOneOf ?? inferredType ;
532- ( effectiveAnyOf , var inferredAnyOf , var nullInAnyOf ) = ProcessCompositionForNull ( AnyOf ) ;
536+ ( var inferredAnyOf , var nullInAnyOf ) = ProcessCompositionForNull ( AnyOf ) ;
533537 hasNullInComposition |= nullInAnyOf ;
534538 inferredType = inferredAnyOf ?? inferredType ;
535539
536- hasOneOfNullAndSingleEnumWith3_0 = nullInOneOf && effectiveOneOf is { Count : 1 } &&
537- 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 && OneOf is not { Count : > 0 } && AnyOf is not { Count : > 0 } )
542+ {
543+ writer . WriteOptionalCollection ( OpenApiConstants . Enum , s_singleNullElementList , ( nodeWriter , s ) => nodeWriter . WriteAny ( s ) ) ;
544+ }
538545 }
539546
540547 // type
541- SerializeTypeProperty ( writer , version , inferredType ) ;
548+ var serializedTypeProperty = SerializeTypeProperty ( writer , version , inferredType ) ;
542549
543550 // allOf
544551 writer . WriteOptionalCollection ( OpenApiConstants . AllOf , AllOf , callback ) ;
545552
546553 // anyOf
547- writer . WriteOptionalCollection ( OpenApiConstants . AnyOf , effectiveAnyOf , callback ) ;
554+ writer . WriteOptionalCollection ( OpenApiConstants . AnyOf , AnyOf , callback ) ;
548555
549556 // 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-
557+ writer . WriteOptionalCollection ( OpenApiConstants . OneOf , OneOf , callback ) ;
571558
572559 // not
573560 writer . WriteOptionalObject ( OpenApiConstants . Not , Not , callback ) ;
@@ -603,8 +590,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
603590 writer . WriteOptionalObject ( OpenApiConstants . Default , Default , ( w , d ) => w . WriteAny ( d ) ) ;
604591
605592 // nullable
606- if ( version == OpenApiSpecVersion . OpenApi3_0 )
593+ if ( version == OpenApiSpecVersion . OpenApi3_0 && serializedTypeProperty )
607594 {
595+ // https://spec.openapis.org/oas/v3.0.4.html#fixed-fields-20
596+ // This keyword only takes effect if type is explicitly defined within the same Schema Object.
597+ //
598+ // If the user explicitly set IsNullable to true, we serialize it even if redundant.
599+ // But if **we** are inferring it (from oneOf/anyOf), we don't serialize it when it's redundant.
608600 SerializeNullable ( writer , version , hasNullInComposition ) ;
609601 }
610602
@@ -995,14 +987,14 @@ private void SerializeAsV2(
995987 writer . WriteEndObject ( ) ;
996988 }
997989
998- private void SerializeTypeProperty ( IOpenApiWriter writer , OpenApiSpecVersion version , JsonSchemaType ? inferredType = null )
990+ private bool SerializeTypeProperty ( IOpenApiWriter writer , OpenApiSpecVersion version , JsonSchemaType ? inferredType = null )
999991 {
1000992 // Use original type or inferred type when the explicit type is not set
1001993 var typeToUse = Type ?? inferredType ;
1002994
1003995 if ( typeToUse is null )
1004996 {
1005- return ;
997+ return false ;
1006998 }
1007999
10081000 var unifiedType = IsNullable ? typeToUse . Value | JsonSchemaType . Null : typeToUse . Value ;
@@ -1014,12 +1006,15 @@ private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion ver
10141006 if ( typeWithoutNull != 0 && ! HasMultipleTypes ( typeWithoutNull ) )
10151007 {
10161008 writer . WriteProperty ( OpenApiConstants . Type , typeWithoutNull . ToFirstIdentifier ( ) ) ;
1009+ return true ;
10171010 }
10181011 break ;
10191012 default :
10201013 WriteUnifiedSchemaType ( unifiedType , writer ) ;
1021- break ;
1014+ return true ;
10221015 }
1016+
1017+ return false ;
10231018 }
10241019
10251020 private static bool IsPowerOfTwo ( int x )
@@ -1075,13 +1070,13 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version
10751070 /// </summary>
10761071 /// <param name="composition">The list of schemas in the composition.</param>
10771072 /// <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 )
1073+ private static ( JsonSchemaType ? inferredType , bool hasNullInComposition )
10791074 ProcessCompositionForNull ( IList < IOpenApiSchema > ? composition )
10801075 {
10811076 if ( composition is null || ! composition . Any ( static s => s . Type is JsonSchemaType . Null ) )
10821077 {
10831078 // Nothing to patch
1084- return ( composition , null , false ) ;
1079+ return ( null , false ) ;
10851080 }
10861081
10871082 var nonNullSchemas = composition
@@ -1114,16 +1109,14 @@ private static (IList<IOpenApiSchema>? effective, JsonSchemaType? inferredType,
11141109
11151110 commonType |= currentType ;
11161111 }
1117-
1118- commonType |= JsonSchemaType . String ;
11191112 }
11201113 }
11211114
1122- return ( nonNullSchemas , commonType == 0 ? null : commonType , true ) ;
1115+ return ( commonType == 0 ? null : commonType , true ) ;
11231116 }
11241117 else
11251118 {
1126- return ( null , null , true ) ;
1119+ return ( null , true ) ;
11271120 }
11281121 }
11291122
0 commit comments