From c0cb10cee7002d7ed5f0157493b4a47755582eda Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Sat, 18 Jul 2026 06:52:02 +0200 Subject: [PATCH 1/6] Handle binary formats for pre-3.1 --- global.json | 2 +- .../Models/OpenApiRequestBody.cs | 21 +-------- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 43 +++++++++++++++++-- .../Reader/V2/OpenApiParameterDeserializer.cs | 12 +++++- .../Reader/V2/OpenApiSchemaDeserializer.cs | 32 ++++++++++++++ .../Reader/V3/OpenApiSchemaDeserializer.cs | 20 +++++++++ .../Models/OpenApiSchemaTests.cs | 2 + 7 files changed, 107 insertions(+), 25 deletions(-) diff --git a/global.json b/global.json index d0c1ec64c..12f5b9de6 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.302" + "version": "10.0.301" } } \ No newline at end of file diff --git a/src/Microsoft.OpenApi/Models/OpenApiRequestBody.cs b/src/Microsoft.OpenApi/Models/OpenApiRequestBody.cs index fa29d3ea0..e659c9a8d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiRequestBody.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiRequestBody.cs @@ -130,26 +130,7 @@ public IEnumerable ConvertToFormDataParameters(IOpenApiWriter { foreach (var property in properties) { - var paramSchema = property.Value.CreateShallowCopy(); - if ((paramSchema.Type & JsonSchemaType.String) == JsonSchemaType.String - && ("binary".Equals(paramSchema.Format, StringComparison.OrdinalIgnoreCase) - || "base64".Equals(paramSchema.Format, StringComparison.OrdinalIgnoreCase))) - { - var updatedSchema = paramSchema switch - { - OpenApiSchema s => s, // we already have a copy - // we have a copy of a reference but don't want to mutate the source schema - // TODO might need recursive resolution of references here - OpenApiSchemaReference r when r.Target is not null => (OpenApiSchema)r.Target.CreateShallowCopy(), - OpenApiSchemaReference => throw new InvalidOperationException("Unresolved reference target"), - _ => throw new InvalidOperationException("Unexpected schema type") - }; - - updatedSchema.Type = "file".ToJsonSchemaType(); - updatedSchema.Format = null; - paramSchema = updatedSchema; - - } + var paramSchema = property.Value; yield return new OpenApiFormDataParameter() { Description = paramSchema.Description, diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 34592d0e6..1bcc03c58 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -589,7 +589,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteProperty(OpenApiConstants.Description, Description); // format - writer.WriteProperty(OpenApiConstants.Format, Format); + var format = Format; + if (version < OpenApiSpecVersion.OpenApi3_1) + { + format ??= GetKnownTypeAndFormatPreOpenApi31()?.Format; + } + + writer.WriteProperty(OpenApiConstants.Format, format); // default writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); @@ -844,7 +850,14 @@ private void SerializeAsV2( writer.WriteProperty(OpenApiConstants.Description, Description); // format - WriteFormatProperty(writer); + if (Format is null && GetKnownTypeAndFormatPreOpenApi31() is { } typeAndFormat) + { + writer.WriteProperty(OpenApiConstants.Format, typeAndFormat.Format); + } + else + { + WriteFormatProperty(writer); + } // title writer.WriteProperty(OpenApiConstants.Title, Title); @@ -1008,7 +1021,15 @@ private void SerializeAsV2( private void SerializeTypePropertyForVersion2(IOpenApiWriter writer) { - if (Type is not { } type || type == JsonSchemaType.Null) + // TODO: Handle "file" type for 2.0. + // Spec https://spec.openapis.org/oas/v2.0.html#data-types + var typeToUse = Type; + if (version < OpenApiSpecVersion.OpenApi3_1) + { + typeToUse ??= GetKnownTypeAndFormatPreOpenApi31()?.Type; + } + + if (typeToUse is not { } type || type == JsonSchemaType.Null) { return; } @@ -1157,6 +1178,22 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version } } + private (JsonSchemaType Type, string Format)? GetKnownTypeAndFormatPreOpenApi31() + { + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + if (Type is not null && Type.Value.HasFlag(JsonSchemaType.String) && ContentEncoding == "base64") + { + return (Type.Value, "byte"); + } + + if (Type is null && !string.IsNullOrEmpty(ContentMediaType)) + { + return (JsonSchemaType.String, "binary"); + } + + return null; + } + #if NET5_0_OR_GREATER private static readonly Array jsonSchemaTypeValues = System.Enum.GetValues(); #else diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs index 5fb2ec88a..ff8e366fe 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiParameterDeserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Text.Json.Nodes; @@ -74,6 +74,16 @@ internal static partial class OpenApiV2Deserializer { var schema = GetOrCreateSchema(o); schema.Type = type.ToJsonSchemaType(); + // TODO: This should be represented using the 3.2 approach. + // The object model must reflect the "latest" version of the spec. + // Note that for parameters in 2.0, the "file" type is specified directly + // on the parameter object. But for responses, the "file" type is an + // extension of the Json Schema object, as in, it's not allowed by + // Json Schema Draft 4, but is allowed as an OpenAPI 2.0 extension. + // All that should be handled correctly. + // The deserialization logic should try to map everything to the "3.2" way + // of doing things. + // And serialization should assume that the object model is in the "3.2" way of doing things. if ("file".Equals(type, StringComparison.OrdinalIgnoreCase)) { schema.Format = "binary"; diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 9e181fdcd..e686582ed 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -277,6 +277,18 @@ internal static partial class OpenApiV2Deserializer OpenApiConstants.PatternPropertiesExtension, (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + OpenApiConstants.ContentEncodingExtension, + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + OpenApiConstants.ContentMediaTypeExtension, + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + OpenApiConstants.ContentSchemaExtension, + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new PatternFieldMap @@ -308,6 +320,26 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum } } + // The object model represents the latest version of the spec. + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + // When we deserialize from V2, we detect the "old way" of specifying binary descriptions, and + // transform it in the object model to the latest thing. + if (schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.String) && + schema.Format == "byte" && + schema.ContentEncoding is null) + { + schema.ContentEncoding = "base64"; + schema.Format = null; + } + + if (schema.Type.HasValue && schema.Type.Value == JsonSchemaType.String && + schema.Format == "binary") + { + schema.ContentMediaType ??= "application/octet-stream"; + schema.Format = null; + schema.Type = null; + } + return schema; } } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 7bb9327b6..911286add 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -438,6 +438,26 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum } } + // The object model represents the latest version of the spec. + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + // When we deserialize from V3, we detect the "old way" of specifying binary descriptions, and + // transform it in the object model to the latest thing. + if (schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.String) && + schema.Format == "byte" && + schema.ContentEncoding is null) + { + schema.ContentEncoding = "base64"; + schema.Format = null; + } + + if (schema.Type.HasValue && schema.Type.Value == JsonSchemaType.String && + schema.Format == "binary") + { + schema.ContentMediaType ??= "application/octet-stream"; + schema.Format = null; + schema.Type = null; + } + return schema; } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 7a94ab513..5948202f6 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1554,6 +1554,8 @@ public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() { var expected = JsonNode.Parse(""" { + "type": "string", + "format": "binary", "x-jsonschema-$anchor": "root", "x-jsonschema-contentEncoding": "base64", "x-jsonschema-contentMediaType": "application/jwt", From 167061f83d364106952487dbd841ec0e71b7c11f Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Tue, 11 Aug 2026 04:32:41 +0200 Subject: [PATCH 2/6] Fix --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 1bcc03c58..f83d484fb 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -1023,11 +1023,7 @@ private void SerializeTypePropertyForVersion2(IOpenApiWriter writer) { // TODO: Handle "file" type for 2.0. // Spec https://spec.openapis.org/oas/v2.0.html#data-types - var typeToUse = Type; - if (version < OpenApiSpecVersion.OpenApi3_1) - { - typeToUse ??= GetKnownTypeAndFormatPreOpenApi31()?.Type; - } + var typeToUse = Type ?? GetKnownTypeAndFormatPreOpenApi31()?.Type; if (typeToUse is not { } type || type == JsonSchemaType.Null) { From 69307b31a0135bb35264ae66a889c94c4a5e1cc6 Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Tue, 11 Aug 2026 05:33:21 +0200 Subject: [PATCH 3/6] Adjust --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 6 +++--- test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index f83d484fb..f73af5459 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -1177,12 +1177,12 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version private (JsonSchemaType Type, string Format)? GetKnownTypeAndFormatPreOpenApi31() { // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 - if (Type is not null && Type.Value.HasFlag(JsonSchemaType.String) && ContentEncoding == "base64") + if (Type == JsonSchemaType.String && ContentEncoding == "base64" && !string.IsNullOrEmpty(ContentMediaType)) { - return (Type.Value, "byte"); + return (JsonSchemaType.String, "byte"); } - if (Type is null && !string.IsNullOrEmpty(ContentMediaType)) + if (Type is null && ContentEncoding is null && !string.IsNullOrEmpty(ContentMediaType)) { return (JsonSchemaType.String, "binary"); } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 5948202f6..7a94ab513 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1554,8 +1554,6 @@ public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() { var expected = JsonNode.Parse(""" { - "type": "string", - "format": "binary", "x-jsonschema-$anchor": "root", "x-jsonschema-contentEncoding": "base64", "x-jsonschema-contentMediaType": "application/jwt", From f01b19c959460b02711f3ed70f625ee71f20bf5f Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Tue, 11 Aug 2026 07:07:47 +0200 Subject: [PATCH 4/6] Add tests (Copilot-generated) --- global.json | 2 +- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 14 +- .../Reader/V3/OpenApiSchemaDeserializer.cs | 2 +- .../V2Tests/OpenApiSchemaTests.cs | 70 ++++++ .../V31Tests/OpenApiSchemaTests.cs | 22 ++ .../Models/OpenApiSchemaTests.cs | 32 +++ .../OpenApiSchemaV30CompatibilityTests.cs | 206 +++++++++++++++++- 7 files changed, 338 insertions(+), 10 deletions(-) diff --git a/global.json b/global.json index 12f5b9de6..d0c1ec64c 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.301" + "version": "10.0.302" } } \ No newline at end of file diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index f73af5459..3d940af40 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -1044,7 +1044,13 @@ private void SerializeTypePropertyForVersion2(IOpenApiWriter writer) /// private void SerializeTypePropertyForVersion3AndLater(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) { - if (Type is not { } type) + var type = Type; + if (version < OpenApiSpecVersion.OpenApi3_1) + { + type ??= GetKnownTypeAndFormatPreOpenApi31()?.Type; + } + + if (type is null) { return; } @@ -1059,7 +1065,7 @@ private void SerializeTypePropertyForVersion3AndLater(IOpenApiWriter writer, Ope var typeWithoutNull = type & ~JsonSchemaType.Null; var hasNull = typeWithoutNull != type; var arrayWithoutNull = (from JsonSchemaType flag in jsonSchemaTypeValues - where typeWithoutNull.HasFlag(flag) + where typeWithoutNull.Value.HasFlag(flag) select flag).ToArray(); // - If we have more than one type (excluding null), we have to use anyOf/oneOf. @@ -1090,7 +1096,7 @@ where typeWithoutNull.HasFlag(flag) else { var array = (from JsonSchemaType flag in jsonSchemaTypeValues - where type.HasFlag(flag) + where type.Value.HasFlag(flag) select flag).ToArray(); if (array.Length > 1) @@ -1177,7 +1183,7 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version private (JsonSchemaType Type, string Format)? GetKnownTypeAndFormatPreOpenApi31() { // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 - if (Type == JsonSchemaType.String && ContentEncoding == "base64" && !string.IsNullOrEmpty(ContentMediaType)) + if (Type == JsonSchemaType.String && ContentEncoding == "base64") { return (JsonSchemaType.String, "byte"); } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 911286add..5c0d9e331 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -444,7 +444,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum // transform it in the object model to the latest thing. if (schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.String) && schema.Format == "byte" && - schema.ContentEncoding is null) + schema.ContentEncoding is null or "base64") { schema.ContentEncoding = "base64"; schema.Format = null; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs index 5afea3cb4..624a40d58 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs @@ -191,5 +191,75 @@ public async Task SerializeSchemaWithOnlyNullableShouldSucceed() Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), schemaString.MakeLineBreaksEnvironmentNeutral()); } + + private static OpenApiSchema LoadV2Schema(string json) + => Assert.IsType( + OpenApiV2Deserializer.LoadSchema(JsonNode.Parse(json), new(), new ParsingContext(new()))); + + private static string SerializeAsV2(OpenApiSchema schema) + { + var writer = new StringWriter(); + schema.SerializeAsV2(new OpenApiJsonWriter(writer)); + return writer.ToString(); + } + + // The object model represents the latest version of the spec, so v2 binary descriptions + // are normalized on read and reconstructed on write. + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + [Fact] + public void ParseSchemaWithByteFormatNormalizesToContentEncoding() + { + var schema = LoadV2Schema("""{ "type": "string", "format": "byte" }"""); + + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal("base64", schema.ContentEncoding); + Assert.Null(schema.Format); + } + + [Fact] + public void ParseSchemaWithBinaryFormatNormalizesToContentMediaType() + { + var schema = LoadV2Schema($$"""{ "type": "string", "format": "byte" }"""); + + Assert.Null(schema.Type); + Assert.Equal("application/octet-stream", schema.ContentMediaType); + Assert.Null(schema.Format); + } + + [Fact] + public void ParseSchemaWithContentEncodingExtensionAssignsContentProperties() + { + var schema = LoadV2Schema(""" + { + "type": "string", + "x-jsonschema-contentEncoding": "base64", + "x-jsonschema-contentMediaType": "image/png", + "x-jsonschema-contentSchema": { "type": "array" } + } + """); + + Assert.Equal("base64", schema.ContentEncoding); + Assert.Equal("image/png", schema.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, schema.ContentSchema?.Type); + Assert.Empty(schema.Extensions ?? new Dictionary()); + } + + [Theory] + [InlineData("""{ "type": "string", "format": "byte" }""")] + [InlineData("""{ "type": "string", "format": "binary" }""")] + public void SchemaWithBinaryDescriptionRoundTripsThroughV2(string original) + { + var schema = LoadV2Schema(original); + + var serialized = SerializeAsV2(schema); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(original), JsonNode.Parse(serialized))); + + // Reading our own output must produce an equivalent model. + var reparsed = LoadV2Schema(serialized); + Assert.Equal(schema.Type, reparsed.Type); + Assert.Equal(schema.Format, reparsed.Format); + Assert.Equal(schema.ContentEncoding, reparsed.ContentEncoding); + Assert.Equal(schema.ContentMediaType, reparsed.ContentMediaType); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index fe4c74ff5..b00ce8fa3 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -372,6 +372,28 @@ public void DefaultEmptyCollectionShouldRoundTrip() Assert.Empty(resultingArray); } + // The pre-3.1 binary description is only normalized when reading v2/v3.0 documents. + // From 3.1 onwards "format" is a plain annotation and must be preserved verbatim. + [Theory] + [InlineData("binary")] + [InlineData("byte")] + public void BinaryFormatIsNotNormalizedInV31(string format) + { + var serializedSchema = $$""" + { + "type": "string", + "format": "{{format}}" + } + """; + + var schema = OpenApiModelFactory.Parse(serializedSchema, OpenApiSpecVersion.OpenApi3_1, new(), out _, "json", SettingsFixture.ReaderSettings); + + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal(format, schema.Format); + Assert.Null(schema.ContentEncoding); + Assert.Null(schema.ContentMediaType); + } + [Fact] public void DefaultNullIsLossyDuringRoundTripJson() { diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 7a94ab513..69f9f539c 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -763,6 +763,38 @@ public async Task WriteAsItemsPropertiesDoesNotWriteNull() """; Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + + // Non-body parameters and headers in v2 serialize the schema inline, so the pre-3.1 + // binary description has to be reconstructed there too. + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + [Theory] + [InlineData("base64", null, """{ "type": "string", "format": "byte" }""")] + [InlineData(null, "image/png", """{ "type": "string", "format": "binary" }""")] + public async Task WriteAsItemsPropertiesReconstructsBinaryDescription( + string contentEncoding, string contentMediaType, string expected) + { + // Arrange + var schema = new OpenApiSchema + { + Type = contentEncoding is null ? null : JsonSchemaType.String, + ContentEncoding = contentEncoding, + ContentMediaType = contentMediaType + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + writer.WriteStartObject(); + + // Act + schema.WriteAsItemsProperties(writer); + writer.WriteEndObject(); + await writer.FlushAsync(); + + // Assert + var actual = outputStringWriter.GetStringBuilder().ToString(); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + [Fact] public async Task SerializeConstAsEnumV30() { diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs index 1c272f837..ebbc0ba2b 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaV30CompatibilityTests.cs @@ -15,7 +15,7 @@ namespace Microsoft.OpenApi.Tests.Models [Collection("DefaultSettings")] public class OpenApiSchemaV30CompatibilityTests { - private static IOpenApiSchema ParseSchemaFromV30Document(string schemaJson) + private static OpenApiSchema ParseSchemaFromV30Document(string schemaJson) { var jsonContent = $$""" { @@ -32,7 +32,7 @@ private static IOpenApiSchema ParseSchemaFromV30Document(string schemaJson) var readResult = OpenApiDocument.Parse(jsonContent, "json"); Assert.Empty(readResult.Diagnostic.Errors); - return readResult.Document.Components.Schemas["TestSchema"]; + return Assert.IsType(readResult.Document.Components.Schemas["TestSchema"]); } [Fact] @@ -257,7 +257,7 @@ public async Task SerializeExclusiveMaximumAsV3EmitsMaximumWithBooleanFlag() """; Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); - var deserializedSchema = (OpenApiSchema)ParseSchemaFromV30Document(actual); + var deserializedSchema = ParseSchemaFromV30Document(actual); Assert.True(deserializedSchema.IsExclusiveMaximum); Assert.Null(deserializedSchema.Maximum); Assert.Equal("5", deserializedSchema.ExclusiveMaximum); @@ -284,10 +284,208 @@ public async Task SerializeExclusiveMinimumAsV3EmitsMinimumWithBooleanFlag() """; Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); - var deserializedSchema = (OpenApiSchema)ParseSchemaFromV30Document(actual); + var deserializedSchema = ParseSchemaFromV30Document(actual); Assert.True(deserializedSchema.IsExclusiveMinimum); Assert.Null(deserializedSchema.Minimum); Assert.Equal("1", deserializedSchema.ExclusiveMinimum); } + + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + [Fact] + public async Task SerializeContentEncodingAsV3EmitsByteFormatAndRoundTrips() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + ContentEncoding = "base64" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "string", + "format": "byte", + "x-jsonschema-contentEncoding": "base64" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(JsonSchemaType.String, deserializedSchema.Type); + Assert.Equal("base64", deserializedSchema.ContentEncoding); + Assert.Null(deserializedSchema.Format); + } + + // The content media type is what carries the binary-ness, so the type is dropped entirely. + [Fact] + public async Task SerializeContentMediaTypeAsV3EmitsBinaryFormatAndRoundTrips() + { + var schema = new OpenApiSchema + { + ContentMediaType = "image/png" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "string", + "format": "binary", + "x-jsonschema-contentMediaType": "image/png" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Null(deserializedSchema.Type); + Assert.Equal("image/png", deserializedSchema.ContentMediaType); + Assert.Null(deserializedSchema.Format); + } + + [Fact] + public async Task SerializeContentEncodingWithMediaTypeAsV3EmitsByteFormatAndRoundTrips() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + ContentEncoding = "base64", + ContentMediaType = "image/png" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "string", + "format": "byte", + "x-jsonschema-contentEncoding": "base64", + "x-jsonschema-contentMediaType": "image/png" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(JsonSchemaType.String, deserializedSchema.Type); + Assert.Equal("base64", deserializedSchema.ContentEncoding); + Assert.Equal("image/png", deserializedSchema.ContentMediaType); + Assert.Null(deserializedSchema.Format); + } + + [Fact] + public async Task SerializeNullableContentEncodingAsV3EmitsByteFormatAndRoundTrips() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + ContentEncoding = "base64" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "string", + "format": "byte", + "nullable": true, + "x-jsonschema-contentEncoding": "base64" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + + var deserializedSchema = ParseSchemaFromV30Document(actual); + Assert.Equal(JsonSchemaType.String | JsonSchemaType.Null, deserializedSchema.Type); + Assert.Equal("base64", deserializedSchema.ContentEncoding); + Assert.Null(deserializedSchema.Format); + } + + [Fact] + public async Task SerializeExplicitFormatAsV3IsNotOverriddenByContentKeywords() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Format = "password", + ContentEncoding = "base64" + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + var expected = + """ + { + "type": "string", + "format": "password", + "x-jsonschema-contentEncoding": "base64" + } + """; + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public void DeserializeByteFormatAsV3NormalizesToContentEncoding() + { + var deserializedSchema = ParseSchemaFromV30Document($$""" + { + "type": "string", + "format": "byte" + } + """); + + Assert.Equal(JsonSchemaType.String, deserializedSchema.Type); + Assert.Equal("base64", deserializedSchema.ContentEncoding); + Assert.Null(deserializedSchema.Format); + } + + [Fact] + public void DeserializeBinaryFormatAsV3NormalizesToContentMediaType() + { + var deserializedSchema = ParseSchemaFromV30Document($$""" + { + "type": "string", + "format": "binary" + } + """); + + Assert.Null(deserializedSchema.Type); + Assert.Equal("application/octet-stream", deserializedSchema.ContentMediaType); + Assert.Null(deserializedSchema.Format); + } + + [Fact] + public void DeserializeByteFormatAsV3WithConflictingEncodingKeepsFormat() + { + var deserializedSchema = ParseSchemaFromV30Document(""" + { + "type": "string", + "format": "byte", + "x-jsonschema-contentEncoding": "base64url" + } + """); + + Assert.Equal(JsonSchemaType.String, deserializedSchema.Type); + Assert.Equal("base64url", deserializedSchema.ContentEncoding); + Assert.Equal("byte", deserializedSchema.Format); + } + + [Fact] + public void DeserializeUnrelatedFormatAsV3IsNotNormalized() + { + var deserializedSchema = ParseSchemaFromV30Document(""" + { + "type": "string", + "format": "date-time" + } + """); + + Assert.Equal(JsonSchemaType.String, deserializedSchema.Type); + Assert.Equal("date-time", deserializedSchema.Format); + Assert.Null(deserializedSchema.ContentEncoding); + Assert.Null(deserializedSchema.ContentMediaType); + } } } From fea12301a485deaf00e45dd74d9ea9ed1dcfdc0c Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Tue, 11 Aug 2026 09:04:09 +0200 Subject: [PATCH 5/6] Progress --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 19 ++---- .../Reader/V2/OpenApiSchemaDeserializer.cs | 2 +- .../V2Tests/OpenApiSchemaTests.cs | 2 +- .../Models/OpenApiParameterTests.cs | 67 +++++++++++++++++++ .../Models/OpenApiSchemaTests.cs | 31 --------- 5 files changed, 76 insertions(+), 45 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 3d940af40..981d099a2 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -762,7 +762,8 @@ private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action !string.IsNullOrEmpty(x.Format))?.Format ?? + formatToWrite = GetKnownTypeAndFormatPreOpenApi31()?.Format ?? + AllOf?.FirstOrDefault(static x => !string.IsNullOrEmpty(x.Format))?.Format ?? AnyOf?.FirstOrDefault(static x => !string.IsNullOrEmpty(x.Format))?.Format ?? OneOf?.FirstOrDefault(static x => !string.IsNullOrEmpty(x.Format))?.Format; } @@ -850,14 +852,7 @@ private void SerializeAsV2( writer.WriteProperty(OpenApiConstants.Description, Description); // format - if (Format is null && GetKnownTypeAndFormatPreOpenApi31() is { } typeAndFormat) - { - writer.WriteProperty(OpenApiConstants.Format, typeAndFormat.Format); - } - else - { - WriteFormatProperty(writer); - } + WriteFormatProperty(writer); // title writer.WriteProperty(OpenApiConstants.Title, Title); @@ -1183,9 +1178,9 @@ private void SerializeNullable(IOpenApiWriter writer, OpenApiSpecVersion version private (JsonSchemaType Type, string Format)? GetKnownTypeAndFormatPreOpenApi31() { // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 - if (Type == JsonSchemaType.String && ContentEncoding == "base64") + if (Type is JsonSchemaType.String or (JsonSchemaType.String | JsonSchemaType.Null) && ContentEncoding == "base64") { - return (JsonSchemaType.String, "byte"); + return (Type.Value, "byte"); } if (Type is null && ContentEncoding is null && !string.IsNullOrEmpty(ContentMediaType)) diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index e686582ed..db51e0781 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -326,7 +326,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum // transform it in the object model to the latest thing. if (schema.Type.HasValue && schema.Type.Value.HasFlag(JsonSchemaType.String) && schema.Format == "byte" && - schema.ContentEncoding is null) + schema.ContentEncoding is null or "base64") { schema.ContentEncoding = "base64"; schema.Format = null; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs index 624a40d58..1d043296a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSchemaTests.cs @@ -219,7 +219,7 @@ public void ParseSchemaWithByteFormatNormalizesToContentEncoding() [Fact] public void ParseSchemaWithBinaryFormatNormalizesToContentMediaType() { - var schema = LoadV2Schema($$"""{ "type": "string", "format": "byte" }"""); + var schema = LoadV2Schema("""{ "type": "string", "format": "binary" }"""); Assert.Null(schema.Type); Assert.Equal("application/octet-stream", schema.ContentMediaType); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs index 0cd2726dd..bb421d8a3 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Text.Json.Nodes; using System.Threading.Tasks; using VerifyXunit; using Xunit; @@ -289,6 +290,72 @@ public async Task SerializeAdvancedParameterAsV2JsonWorks() Assert.Equal(expected, actual); } + // A v2 non-body parameter serializes its schema inline rather than as a nested "schema" + // object, so the pre-3.1 binary description has to be reconstructed there as well. + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + [Fact] + public async Task SerializeParameterWithContentEncodingAsV2JsonReconstructsByteFormat() + { + // Arrange + var parameter = new OpenApiParameter + { + Name = "token", + In = ParameterLocation.Query, + Schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + ContentEncoding = "base64" + } + }; + + var expected = + """ + { + "in": "query", + "name": "token", + "type": "string", + "format": "byte" + } + """; + + // Act + var actual = await parameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public async Task SerializeParameterWithContentMediaTypeAsV2JsonReconstructsBinaryFormat() + { + // Arrange + var parameter = new OpenApiParameter + { + Name = "upload", + In = ParameterLocation.Query, + Schema = new OpenApiSchema + { + ContentMediaType = "image/png" + } + }; + + var expected = + """ + { + "in": "query", + "name": "upload", + "type": "string", + "format": "binary" + } + """; + + // Act + var actual = await parameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 69f9f539c..09461071e 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -764,37 +764,6 @@ public async Task WriteAsItemsPropertiesDoesNotWriteNull() Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - // Non-body parameters and headers in v2 serialize the schema inline, so the pre-3.1 - // binary description has to be reconstructed there too. - // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 - [Theory] - [InlineData("base64", null, """{ "type": "string", "format": "byte" }""")] - [InlineData(null, "image/png", """{ "type": "string", "format": "binary" }""")] - public async Task WriteAsItemsPropertiesReconstructsBinaryDescription( - string contentEncoding, string contentMediaType, string expected) - { - // Arrange - var schema = new OpenApiSchema - { - Type = contentEncoding is null ? null : JsonSchemaType.String, - ContentEncoding = contentEncoding, - ContentMediaType = contentMediaType - }; - - var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); - var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); - writer.WriteStartObject(); - - // Act - schema.WriteAsItemsProperties(writer); - writer.WriteEndObject(); - await writer.FlushAsync(); - - // Assert - var actual = outputStringWriter.GetStringBuilder().ToString(); - Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); - } - [Fact] public async Task SerializeConstAsEnumV30() { From 0cb8072b2810ac4fd01dbb510e82c1a93f7ad4ad Mon Sep 17 00:00:00 2001 From: Youssef1313 Date: Tue, 11 Aug 2026 09:43:31 +0200 Subject: [PATCH 6/6] Add test (Copilot) --- .../Models/OpenApiRequestBodyTests.cs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs index d3df1e1ed..c68bafa9b 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiRequestBodyTests.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; +using System.Text.Json.Nodes; using System.Threading.Tasks; using VerifyXunit; using Xunit; @@ -96,5 +98,142 @@ public async Task SerializeReferencedRequestBodyAsV3JsonWithoutReferenceWorksAsy // Assert await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); } + + // A multipart body covering every way of describing a form field, including the + // pre-3.1 and 3.1+ spellings of binary content. + // https://spec.openapis.org/oas/v3.2.0.html#migrating-binary-descriptions-from-oas-3-0 + private static OpenApiRequestBody MultipartRequestBody => new() + { + Required = true, + Content = new Dictionary() + { + ["multipart/form-data"] = new OpenApiMediaType() + { + Schema = new OpenApiSchema() + { + Type = JsonSchemaType.Object, + Required = new HashSet { "file" }, + Properties = new Dictionary() + { + // 3.1+ spelling of raw binary content. + ["file"] = new OpenApiSchema() { ContentMediaType = "application/octet-stream" }, + // Pre-3.1 spelling of raw binary content. + ["legacyFile"] = new OpenApiSchema() { Type = JsonSchemaType.String, Format = "binary" }, + // 3.1+ spelling of base64 encoded content. + ["token"] = new OpenApiSchema() { Type = JsonSchemaType.String, ContentEncoding = "base64" }, + ["comment"] = new OpenApiSchema() + { + Type = JsonSchemaType.String, + Description = "description1" + }, + } + } + } + } + }; + + [Fact] + public void ConvertToFormDataParametersProjectsSchemaPropertiesOntoParameters() + { + // Arrange + var requestBody = MultipartRequestBody; + var bodySchema = (OpenApiSchema)requestBody.Content["multipart/form-data"].Schema!; + var writer = new OpenApiJsonWriter(new StringWriter(CultureInfo.InvariantCulture)); + + // Act + var parameters = requestBody.ConvertToFormDataParameters(writer).ToList(); + + // Assert + Assert.Collection(parameters, + file => + { + Assert.Equal("file", file.Name); + Assert.True(file.Required); + Assert.Null(file.Description); + }, + legacyFile => + { + Assert.Equal("legacyFile", legacyFile.Name); + Assert.False(legacyFile.Required); + }, + token => + { + Assert.Equal("token", token.Name); + Assert.False(token.Required); + }, + comment => + { + Assert.Equal("comment", comment.Name); + Assert.False(comment.Required); + Assert.Equal("description1", comment.Description); + }); + + // The conversion must not mutate the schema it was derived from. + var fileSchema = (OpenApiSchema)bodySchema.Properties["file"]; + Assert.Null(fileSchema.Type); + Assert.Null(fileSchema.Format); + Assert.Equal("application/octet-stream", fileSchema.ContentMediaType); + } + + [Fact] + public async Task SerializeOperationWithBinaryFormDataAsV2JsonWorks() + { + // Arrange + var operation = new OpenApiOperation + { + RequestBody = MultipartRequestBody, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Uploaded." } + } + }; + + var expected = + """ + { + "consumes": [ + "multipart/form-data" + ], + "parameters": [ + { + "in": "formData", + "name": "file", + "required": true, + "type": "string", + "format": "binary" + }, + { + "in": "formData", + "name": "legacyFile", + "type": "string", + "format": "binary" + }, + { + "in": "formData", + "name": "token", + "type": "string", + "format": "byte" + }, + { + "in": "formData", + "name": "comment", + "description": "description1", + "type": "string" + } + ], + "responses": { + "200": { + "description": "Uploaded." + } + } + } + """; + + // Act + var actual = await operation.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } } }