From dd5085a394d8ab23208c12be9bbcbd25bed6ce3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:20:38 +0000 Subject: [PATCH 1/2] feat(library): preserve PatternProperties as x-jsonschema-patternProperties extension for OpenAPI v2/v3.0 serialization - Add PatternPropertiesExtension constant ("x-jsonschema-patternProperties") - Emit extension when serializing to OpenAPI v2.0 or v3.0 and PatternProperties is non-empty - Parse the extension back into PatternProperties when deserializing v2.0 or v3.0 documents - Add unit tests for both serialization and deserialization round-trip Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../Models/OpenApiConstants.cs | 5 + src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 12 ++ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 1 + .../Reader/V2/OpenApiSchemaDeserializer.cs | 4 + .../Reader/V3/OpenApiSchemaDeserializer.cs | 4 + .../Models/OpenApiSchemaTests.cs | 136 ++++++++++++++++++ 6 files changed, 162 insertions(+) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 7af00afb7..5fdf13d12 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -495,6 +495,11 @@ public static class OpenApiConstants /// public const string PatternProperties = "patternProperties"; + /// + /// Extension: x-jsonschema-patternProperties + /// + public const string PatternPropertiesExtension = "x-jsonschema-patternProperties"; + /// /// Field: AdditionalProperties /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 192aea29d..85f899bf5 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -556,6 +556,12 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); writer.WriteValue(false); } + + // Write patternProperties as an extension + if (PatternProperties is { Count: > 0 }) + { + writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback); + } } // extensions @@ -836,6 +842,12 @@ private void SerializeAsV2( writer.WriteValue(false); } + // Write patternProperties as an extension + if (PatternProperties is { Count: > 0 }) + { + writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, (w, s) => s.SerializeAsV2(w)); + } + // extensions writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..8513259ea 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +const Microsoft.OpenApi.OpenApiConstants.PatternPropertiesExtension = "x-jsonschema-patternProperties" -> string! diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs index 028014664..8c4104cc3 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs @@ -241,6 +241,10 @@ internal static partial class OpenApiV2Deserializer "example", (o, n, _) => o.Example = n.CreateAny() }, + { + OpenApiConstants.PatternPropertiesExtension, + (o, n, t) => o.PatternProperties = n.CreateMap(LoadSchema, t) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new PatternFieldMap diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index fd73cec99..804c8567e 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -276,6 +276,10 @@ internal static partial class OpenApiV3Deserializer } } }, + { + OpenApiConstants.PatternPropertiesExtension, + (o, n, t) => o.PatternProperties = n.CreateMap(LoadSchema, t) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index d5e785eac..93ce82c2d 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1308,6 +1308,142 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions( Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + // PatternProperties tests + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_2)] + public async Task SerializePatternPropertiesAsKeywordInV31AndV32(OpenApiSpecVersion version) + { + var expected = @"{ ""patternProperties"": { ""^[a-z]+"": { ""type"": ""string"" } } }"; + // Given - patternProperties should be emitted as a standard keyword in v3.1+ + var schema = new OpenApiSchema + { + PatternProperties = new Dictionary + { + ["^[a-z]+"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }; + + // When + var actual = await schema.SerializeAsJsonAsync(version); + + // Then + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task SerializePatternPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version) + { + var expected = @"{ ""x-jsonschema-patternProperties"": { ""^[a-z]+"": { ""type"": ""string"" } } }"; + // Given - patternProperties should be emitted as extension in versions < 3.1 + var schema = new OpenApiSchema + { + PatternProperties = new Dictionary + { + ["^[a-z]+"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }; + + // When + var actual = await schema.SerializeAsJsonAsync(version); + + // Then + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task SerializeEmptyPatternPropertiesNotEmittedInEarlierVersions(OpenApiSpecVersion version) + { + var expected = @"{ }"; + // Given - empty patternProperties should not emit extension + var schema = new OpenApiSchema + { + PatternProperties = new Dictionary() + }; + + // When + var actual = await schema.SerializeAsJsonAsync(version); + + // Then + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public void DeserializePatternPropertiesExtensionInV2AssignsPatternPropertiesProperty() + { + // Given - a V2 document with x-jsonschema-patternProperties extension in a definition + var jsonContent = """ + { + "swagger": "2.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "definitions": { + "TestSchema": { + "type": "object", + "x-jsonschema-patternProperties": { + "^[a-z]+": { "type": "string" } + } + } + } + } + """; + + // When + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + // Then + Assert.Empty(readResult.Diagnostic.Errors); + var schema = readResult.Document.Components.Schemas["TestSchema"]; + Assert.NotNull(schema); + Assert.NotNull(schema.PatternProperties); + Assert.Single(schema.PatternProperties); + Assert.True(schema.PatternProperties.ContainsKey("^[a-z]+")); + Assert.Equal(JsonSchemaType.String, schema.PatternProperties["^[a-z]+"].Type); + // Extension should NOT be present on the schema (it was consumed) + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey("x-jsonschema-patternProperties")); + } + + [Fact] + public void DeserializePatternPropertiesExtensionInV3AssignsPatternPropertiesProperty() + { + // Given - a V3 document with x-jsonschema-patternProperties extension in a component schema + var jsonContent = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": { + "type": "object", + "x-jsonschema-patternProperties": { + "^[a-z]+": { "type": "string" } + } + } + } + } + } + """; + + // When + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + // Then + Assert.Empty(readResult.Diagnostic.Errors); + var schema = readResult.Document.Components.Schemas["TestSchema"]; + Assert.NotNull(schema); + Assert.NotNull(schema.PatternProperties); + Assert.Single(schema.PatternProperties); + Assert.True(schema.PatternProperties.ContainsKey("^[a-z]+")); + Assert.Equal(JsonSchemaType.String, schema.PatternProperties["^[a-z]+"].Type); + // Extension should NOT be present on the schema (it was consumed) + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey("x-jsonschema-patternProperties")); + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new(); From 26f72707f02a3e40e869f6c0a780ef6f1c0f9b6f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 5 Mar 2026 10:24:23 -0500 Subject: [PATCH 2/2] chore: removes 32 unit test --- test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 93ce82c2d..1e924cad3 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1311,7 +1311,6 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions( // PatternProperties tests [Theory] [InlineData(OpenApiSpecVersion.OpenApi3_1)] - [InlineData(OpenApiSpecVersion.OpenApi3_2)] public async Task SerializePatternPropertiesAsKeywordInV31AndV32(OpenApiSpecVersion version) { var expected = @"{ ""patternProperties"": { ""^[a-z]+"": { ""type"": ""string"" } } }";