Skip to content

Commit d969fdc

Browse files
Copilotbaywet
andcommitted
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>
1 parent 871019c commit d969fdc

File tree

6 files changed

+162
-0
lines changed

6 files changed

+162
-0
lines changed

src/Microsoft.OpenApi/Models/OpenApiConstants.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,11 @@ public static class OpenApiConstants
535535
/// </summary>
536536
public const string PatternProperties = "patternProperties";
537537

538+
/// <summary>
539+
/// Extension: x-jsonschema-patternProperties
540+
/// </summary>
541+
public const string PatternPropertiesExtension = "x-jsonschema-patternProperties";
542+
538543
/// <summary>
539544
/// Field: AdditionalProperties
540545
/// </summary>

src/Microsoft.OpenApi/Models/OpenApiSchema.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,12 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
561561
writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension);
562562
writer.WriteValue(false);
563563
}
564+
565+
// Write patternProperties as an extension
566+
if (PatternProperties is { Count: > 0 })
567+
{
568+
writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback);
569+
}
564570
}
565571

566572
// extensions
@@ -841,6 +847,12 @@ private void SerializeAsV2(
841847
writer.WriteValue(false);
842848
}
843849

850+
// Write patternProperties as an extension
851+
if (PatternProperties is { Count: > 0 })
852+
{
853+
writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, (w, s) => s.SerializeAsV2(w));
854+
}
855+
844856
// extensions
845857
writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0);
846858

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
const Microsoft.OpenApi.OpenApiConstants.PatternPropertiesExtension = "x-jsonschema-patternProperties" -> string!

src/Microsoft.OpenApi/Reader/V2/OpenApiSchemaDeserializer.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@ internal static partial class OpenApiV2Deserializer
241241
"example",
242242
(o, n, _) => o.Example = n.CreateAny()
243243
},
244+
{
245+
OpenApiConstants.PatternPropertiesExtension,
246+
(o, n, t) => o.PatternProperties = n.CreateMap(LoadSchema, t)
247+
},
244248
};
245249

246250
private static readonly PatternFieldMap<OpenApiSchema> _openApiSchemaPatternFields = new PatternFieldMap<OpenApiSchema>

src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ internal static partial class OpenApiV3Deserializer
276276
}
277277
}
278278
},
279+
{
280+
OpenApiConstants.PatternPropertiesExtension,
281+
(o, n, t) => o.PatternProperties = n.CreateMap(LoadSchema, t)
282+
},
279283
};
280284

281285
private static readonly PatternFieldMap<OpenApiSchema> _openApiSchemaPatternFields = new()

test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,142 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions(
13121312
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
13131313
}
13141314

1315+
// PatternProperties tests
1316+
[Theory]
1317+
[InlineData(OpenApiSpecVersion.OpenApi3_1)]
1318+
[InlineData(OpenApiSpecVersion.OpenApi3_2)]
1319+
public async Task SerializePatternPropertiesAsKeywordInV31AndV32(OpenApiSpecVersion version)
1320+
{
1321+
var expected = @"{ ""patternProperties"": { ""^[a-z]+"": { ""type"": ""string"" } } }";
1322+
// Given - patternProperties should be emitted as a standard keyword in v3.1+
1323+
var schema = new OpenApiSchema
1324+
{
1325+
PatternProperties = new Dictionary<string, IOpenApiSchema>
1326+
{
1327+
["^[a-z]+"] = new OpenApiSchema { Type = JsonSchemaType.String }
1328+
}
1329+
};
1330+
1331+
// When
1332+
var actual = await schema.SerializeAsJsonAsync(version);
1333+
1334+
// Then
1335+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
1336+
}
1337+
1338+
[Theory]
1339+
[InlineData(OpenApiSpecVersion.OpenApi2_0)]
1340+
[InlineData(OpenApiSpecVersion.OpenApi3_0)]
1341+
public async Task SerializePatternPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version)
1342+
{
1343+
var expected = @"{ ""x-jsonschema-patternProperties"": { ""^[a-z]+"": { ""type"": ""string"" } } }";
1344+
// Given - patternProperties should be emitted as extension in versions < 3.1
1345+
var schema = new OpenApiSchema
1346+
{
1347+
PatternProperties = new Dictionary<string, IOpenApiSchema>
1348+
{
1349+
["^[a-z]+"] = new OpenApiSchema { Type = JsonSchemaType.String }
1350+
}
1351+
};
1352+
1353+
// When
1354+
var actual = await schema.SerializeAsJsonAsync(version);
1355+
1356+
// Then
1357+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
1358+
}
1359+
1360+
[Theory]
1361+
[InlineData(OpenApiSpecVersion.OpenApi2_0)]
1362+
[InlineData(OpenApiSpecVersion.OpenApi3_0)]
1363+
public async Task SerializeEmptyPatternPropertiesNotEmittedInEarlierVersions(OpenApiSpecVersion version)
1364+
{
1365+
var expected = @"{ }";
1366+
// Given - empty patternProperties should not emit extension
1367+
var schema = new OpenApiSchema
1368+
{
1369+
PatternProperties = new Dictionary<string, IOpenApiSchema>()
1370+
};
1371+
1372+
// When
1373+
var actual = await schema.SerializeAsJsonAsync(version);
1374+
1375+
// Then
1376+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
1377+
}
1378+
1379+
[Fact]
1380+
public void DeserializePatternPropertiesExtensionInV2AssignsPatternPropertiesProperty()
1381+
{
1382+
// Given - a V2 document with x-jsonschema-patternProperties extension in a definition
1383+
var jsonContent = """
1384+
{
1385+
"swagger": "2.0",
1386+
"info": { "title": "Test", "version": "1.0" },
1387+
"paths": {},
1388+
"definitions": {
1389+
"TestSchema": {
1390+
"type": "object",
1391+
"x-jsonschema-patternProperties": {
1392+
"^[a-z]+": { "type": "string" }
1393+
}
1394+
}
1395+
}
1396+
}
1397+
""";
1398+
1399+
// When
1400+
var readResult = OpenApiDocument.Parse(jsonContent, "json");
1401+
1402+
// Then
1403+
Assert.Empty(readResult.Diagnostic.Errors);
1404+
var schema = readResult.Document.Components.Schemas["TestSchema"];
1405+
Assert.NotNull(schema);
1406+
Assert.NotNull(schema.PatternProperties);
1407+
Assert.Single(schema.PatternProperties);
1408+
Assert.True(schema.PatternProperties.ContainsKey("^[a-z]+"));
1409+
Assert.Equal(JsonSchemaType.String, schema.PatternProperties["^[a-z]+"].Type);
1410+
// Extension should NOT be present on the schema (it was consumed)
1411+
Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey("x-jsonschema-patternProperties"));
1412+
}
1413+
1414+
[Fact]
1415+
public void DeserializePatternPropertiesExtensionInV3AssignsPatternPropertiesProperty()
1416+
{
1417+
// Given - a V3 document with x-jsonschema-patternProperties extension in a component schema
1418+
var jsonContent = """
1419+
{
1420+
"openapi": "3.0.0",
1421+
"info": { "title": "Test", "version": "1.0" },
1422+
"paths": {},
1423+
"components": {
1424+
"schemas": {
1425+
"TestSchema": {
1426+
"type": "object",
1427+
"x-jsonschema-patternProperties": {
1428+
"^[a-z]+": { "type": "string" }
1429+
}
1430+
}
1431+
}
1432+
}
1433+
}
1434+
""";
1435+
1436+
// When
1437+
var readResult = OpenApiDocument.Parse(jsonContent, "json");
1438+
1439+
// Then
1440+
Assert.Empty(readResult.Diagnostic.Errors);
1441+
var schema = readResult.Document.Components.Schemas["TestSchema"];
1442+
Assert.NotNull(schema);
1443+
Assert.NotNull(schema.PatternProperties);
1444+
Assert.Single(schema.PatternProperties);
1445+
Assert.True(schema.PatternProperties.ContainsKey("^[a-z]+"));
1446+
Assert.Equal(JsonSchemaType.String, schema.PatternProperties["^[a-z]+"].Type);
1447+
// Extension should NOT be present on the schema (it was consumed)
1448+
Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey("x-jsonschema-patternProperties"));
1449+
}
1450+
13151451
internal class SchemaVisitor : OpenApiVisitorBase
13161452
{
13171453
public List<string> Titles = new();

0 commit comments

Comments
 (0)