diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index f31644c1f..1ffccd272 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -438,23 +438,39 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // enum var enumValue = Enum is not { Count: > 0 } - && !string.IsNullOrEmpty(Const) + && !string.IsNullOrEmpty(Const) && version < OpenApiSpecVersion.OpenApi3_1 ? new List { JsonValue.Create(Const)! } : Enum; writer.WriteOptionalCollection(OpenApiConstants.Enum, enumValue, (nodeWriter, s) => nodeWriter.WriteAny(s)); + // Handle oneOf/anyOf with null type for v3.0 downcast + IList? effectiveOneOf = OneOf; + IList? effectiveAnyOf = AnyOf; + bool hasNullInComposition = false; + JsonSchemaType? inferredType = null; + + if (version == OpenApiSpecVersion.OpenApi3_0) + { + (effectiveOneOf, var inferredOneOf, var nullInOneOf) = ProcessCompositionForNull(OneOf); + hasNullInComposition |= nullInOneOf; + inferredType = inferredOneOf ?? inferredType; + (effectiveAnyOf, var inferredAnyOf, var nullInAnyOf) = ProcessCompositionForNull(AnyOf); + hasNullInComposition |= nullInAnyOf; + inferredType = inferredAnyOf ?? inferredType; + } + // type - SerializeTypeProperty(writer, version); + SerializeTypeProperty(writer, version, inferredType); // allOf writer.WriteOptionalCollection(OpenApiConstants.AllOf, AllOf, callback); // anyOf - writer.WriteOptionalCollection(OpenApiConstants.AnyOf, AnyOf, callback); + writer.WriteOptionalCollection(OpenApiConstants.AnyOf, effectiveAnyOf, callback); // oneOf - writer.WriteOptionalCollection(OpenApiConstants.OneOf, OneOf, callback); + writer.WriteOptionalCollection(OpenApiConstants.OneOf, effectiveOneOf, callback); // not writer.WriteOptionalObject(OpenApiConstants.Not, Not, callback); @@ -493,7 +509,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // nullable if (version == OpenApiSpecVersion.OpenApi3_0) { - SerializeNullable(writer, version); + SerializeNullable(writer, version, hasNullInComposition); } // discriminator @@ -766,14 +782,17 @@ private void SerializeAsV2( writer.WriteEndObject(); } - private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version) + private void SerializeTypeProperty(IOpenApiWriter writer, OpenApiSpecVersion version, JsonSchemaType? inferredType = null) { - if (Type is null) + // Use original type or inferred type when the explicit type is not set + var typeToUse = Type ?? inferredType; + + if (typeToUse is null) { return; } - var unifiedType = IsNullable ? Type.Value | JsonSchemaType.Null : Type.Value; + var unifiedType = IsNullable ? typeToUse.Value | JsonSchemaType.Null : typeToUse.Value; var typeWithoutNull = unifiedType & ~JsonSchemaType.Null; switch (version) @@ -804,8 +823,8 @@ private static bool HasMultipleTypes(JsonSchemaType schemaType) private static void WriteUnifiedSchemaType(JsonSchemaType type, IOpenApiWriter writer) { var array = (from JsonSchemaType flag in jsonSchemaTypeValues - where type.HasFlag(flag) - select flag.ToFirstIdentifier()).ToArray(); + where type.HasFlag(flag) + select flag.ToFirstIdentifier()).ToArray(); if (array.Length > 1) { writer.WriteOptionalCollection(OpenApiConstants.Type, array, (w, s) => @@ -822,9 +841,9 @@ where type.HasFlag(flag) } } - private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version) + private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version, bool hasNullInComposition = false) { - if (IsNullable) + if (IsNullable || hasNullInComposition) { switch (version) { @@ -838,6 +857,41 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version } } + /// + /// Processes a composition (oneOf or anyOf) for null types, filtering out null schemas and inferring common type. + /// + /// The list of schemas in the composition. + /// A tuple with the effective list, inferred type, and whether null is present in composition. + private static (IList? effective, JsonSchemaType? inferredType, bool hasNullInComposition) + ProcessCompositionForNull(IList? composition) + { + if (composition is null || !composition.Any(static s => s.Type is JsonSchemaType.Null)) + { + // Nothing to patch + return (composition, null, false); + } + + var nonNullSchemas = composition + .Where(static s => s.Type is null or not JsonSchemaType.Null) + .ToList(); + + if (nonNullSchemas.Count > 0) + { + JsonSchemaType commonType = 0; + + foreach (var schema in nonNullSchemas) + { + commonType |= schema.Type.GetValueOrDefault() & ~JsonSchemaType.Null; + } + + return (nonNullSchemas, commonType, true); + } + else + { + return (null, null, true); + } + } + #if NET5_0_OR_GREATER private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues(); #else diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 6ffbb9fe4..c20da3127 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -790,6 +790,319 @@ public async Task SerializeAdditionalPropertiesAsV3PlusEmits(OpenApiSpecVersion Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + [Fact] + public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync() + { + // Arrange - oneOf with null and a reference-like schema + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema + { + Type = JsonSchemaType.String, + MaxLength = 10 + } + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "type": "string", + "oneOf": [ + { + "maxLength": 10, + "type": "string" + } + ], + "nullable": true + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithNullAndMultipleSchemasAsV3ShouldMarkItAsNullableWithoutType() + { + // Arrange - oneOf with null, string, and number + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema { Type = JsonSchemaType.String }, + new OpenApiSchema { Type = JsonSchemaType.Number }, + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "nullable": true + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeAnyOfWithNullAsV3ShouldUseNullableAsync() + { + // Arrange - anyOf with null and object schema + var schema = new OpenApiSchema + { + AnyOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + } + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + ], + "nullable": true + } + """; + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeAnyOfWithNullAndMultipleSchemasAsV3ShouldApplyNullable() + { + // Arrange - anyOf with null and multiple schemas + var schema = new OpenApiSchema + { + AnyOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema { Type = JsonSchemaType.String, MinLength = 1 }, + new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "integer" + } + ], + "nullable": true + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithOnlyNullAsV3ShouldJustBeNullableAsync() + { + // Arrange - oneOf with only null + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null } + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "nullable": true + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + + [Fact] + public async Task SerializeOneOfWithNullAsV31ShouldNotChangeAsync() + { + // Arrange - oneOf with null should remain unchanged in v3.1 + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema { Type = JsonSchemaType.String } + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV31(writer); + await writer.FlushAsync(); + + var v31Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV31Schema = + """ + { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV31Schema), JsonNode.Parse(v31Schema))); + } + + [Fact] + public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() + { + // Arrange - oneOf with null and a $ref to a schema component + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Pet"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer }, + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + }; + + // Register components so references can be resolved + document.Workspace.RegisterComponents(document); + + var schemaRef = new OpenApiSchemaReference("Pet", document); + + var schema = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Null }, + schemaRef + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ], + "nullable": true + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } internal class SchemaVisitor : OpenApiVisitorBase {