Skip to content

Commit 1570f61

Browse files
committed
Serialize patternProperties for OpenAPI v3.0
Adds support for patternProperties when serializing OpenApiSchema for OpenAPI 3.0 by emitting an extension and a fallback additionalProperties value. Introduces a new constant x-jsonSchema-patternProperties in OpenApiConstants, updates OpenApiSchema.SerializeInternal to write the extension for v3.0 and to derive additionalProperties fallback: if all pattern property schemas are identical the common schema is emitted, otherwise additionalProperties: true is emitted. Implements helper methods to compare serialized schemas (SerializeSchemaToComparableJsonNode and TryGetPatternPropertiesFallbackSchema) and adds necessary usings. Adds unit tests covering v3.0 extension + schema fallback, v3.0 extension + true fallback when schemas differ, and that v3.1 still uses the standard patternProperties keyword.
1 parent 404d106 commit 1570f61

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

src/Microsoft.OpenApi/Models/OpenApiConstants.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ public static class OpenApiConstants
135135
/// </summary>
136136
public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties";
137137

138+
/// <summary>
139+
/// Extension: x-jsonSchema-patternProperties
140+
/// </summary>
141+
public const string PatternPropertiesExtension = "x-jsonSchema-patternProperties";
142+
138143
/// <summary>
139144
/// Field: Version
140145
/// </summary>

src/Microsoft.OpenApi/Models/OpenApiSchema.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Globalization;
7+
using System.IO;
68
using System.Linq;
79
using System.Text.Json;
810
using System.Text.Json.Nodes;
@@ -493,6 +495,13 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
493495
// properties
494496
writer.WriteOptionalMap(OpenApiConstants.Properties, Properties, callback);
495497

498+
var hasPatternPropertiesForV30 = version == OpenApiSpecVersion.OpenApi3_0 && PatternProperties is { Count: > 0 };
499+
500+
if (hasPatternPropertiesForV30)
501+
{
502+
writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback);
503+
}
504+
496505
// additionalProperties
497506
if (AdditionalProperties is not null && version >= OpenApiSpecVersion.OpenApi3_0)
498507
{
@@ -501,6 +510,20 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
501510
AdditionalProperties,
502511
callback);
503512
}
513+
else if (hasPatternPropertiesForV30)
514+
{
515+
if (TryGetPatternPropertiesFallbackSchema(out var fallbackSchema) && fallbackSchema is not null)
516+
{
517+
writer.WriteOptionalObject(
518+
OpenApiConstants.AdditionalProperties,
519+
fallbackSchema,
520+
callback);
521+
}
522+
else
523+
{
524+
writer.WriteProperty(OpenApiConstants.AdditionalProperties, true);
525+
}
526+
}
504527
// true is the default, no need to write it out
505528
else if (!AdditionalPropertiesAllowed)
506529
{
@@ -611,6 +634,54 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer)
611634
writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s));
612635
}
613636

637+
private bool TryGetPatternPropertiesFallbackSchema(out IOpenApiSchema? fallbackSchema)
638+
{
639+
fallbackSchema = null;
640+
if (PatternProperties is not { Count: > 0 })
641+
{
642+
return false;
643+
}
644+
645+
fallbackSchema = PatternProperties.First().Value;
646+
if (PatternProperties.Count == 1)
647+
{
648+
return fallbackSchema is not null;
649+
}
650+
651+
var baselineNode = SerializeSchemaToComparableJsonNode(fallbackSchema);
652+
if (baselineNode is null)
653+
{
654+
fallbackSchema = null;
655+
return false;
656+
}
657+
658+
foreach (var schema in PatternProperties.Skip(1).Select(static x => x.Value))
659+
{
660+
var schemaNode = SerializeSchemaToComparableJsonNode(schema);
661+
if (schemaNode is null || !JsonNode.DeepEquals(baselineNode, schemaNode))
662+
{
663+
fallbackSchema = null;
664+
return false;
665+
}
666+
}
667+
668+
return true;
669+
}
670+
671+
private static JsonNode? SerializeSchemaToComparableJsonNode(IOpenApiSchema schema)
672+
{
673+
if (schema is not IOpenApiSerializable serializableSchema)
674+
{
675+
return null;
676+
}
677+
678+
using var stringWriter = new StringWriter(CultureInfo.InvariantCulture);
679+
var jsonWriter = new OpenApiJsonWriter(stringWriter, new OpenApiJsonWriterSettings { Terse = true });
680+
serializableSchema.SerializeAsV31(jsonWriter);
681+
682+
return JsonNode.Parse(stringWriter.ToString());
683+
}
684+
614685
internal void WriteAsItemsProperties(IOpenApiWriter writer)
615686
{
616687
// type

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,128 @@ public async Task SerializeAdditionalPropertiesAsV3PlusEmits(OpenApiSpecVersion
828828
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
829829
}
830830

831+
[Fact]
832+
public async Task SerializePatternPropertiesAsV3EmitsExtensionAndSchemaFallback()
833+
{
834+
// Given
835+
var schema = new OpenApiSchema
836+
{
837+
Type = JsonSchemaType.Object,
838+
AdditionalPropertiesAllowed = false,
839+
PatternProperties = new Dictionary<string, IOpenApiSchema>
840+
{
841+
["^[a-z][a-z0-9_]*$"] = new OpenApiSchema
842+
{
843+
Type = JsonSchemaType.Integer,
844+
Format = "int32"
845+
}
846+
}
847+
};
848+
849+
var expected =
850+
"""
851+
{
852+
"type": "object",
853+
"x-jsonSchema-patternProperties": {
854+
"^[a-z][a-z0-9_]*$": {
855+
"type": "integer",
856+
"format": "int32"
857+
}
858+
},
859+
"additionalProperties": {
860+
"type": "integer",
861+
"format": "int32"
862+
}
863+
}
864+
""";
865+
866+
// When
867+
var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
868+
869+
// Then
870+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
871+
}
872+
873+
[Fact]
874+
public async Task SerializePatternPropertiesAsV3EmitsExtensionAndTrueFallbackWhenSchemasDiffer()
875+
{
876+
// Given
877+
var schema = new OpenApiSchema
878+
{
879+
Type = JsonSchemaType.Object,
880+
PatternProperties = new Dictionary<string, IOpenApiSchema>
881+
{
882+
["^[a-z]+$"] = new OpenApiSchema
883+
{
884+
Type = JsonSchemaType.String
885+
},
886+
["^[0-9]+$"] = new OpenApiSchema
887+
{
888+
Type = JsonSchemaType.Integer,
889+
Format = "int32"
890+
}
891+
}
892+
};
893+
894+
var expected =
895+
"""
896+
{
897+
"type": "object",
898+
"x-jsonSchema-patternProperties": {
899+
"^[a-z]+$": {
900+
"type": "string"
901+
},
902+
"^[0-9]+$": {
903+
"type": "integer",
904+
"format": "int32"
905+
}
906+
},
907+
"additionalProperties": true
908+
}
909+
""";
910+
911+
// When
912+
var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
913+
914+
// Then
915+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
916+
}
917+
918+
[Fact]
919+
public async Task SerializePatternPropertiesAsV31RemainsStandardKeyword()
920+
{
921+
// Given
922+
var schema = new OpenApiSchema
923+
{
924+
Type = JsonSchemaType.Object,
925+
PatternProperties = new Dictionary<string, IOpenApiSchema>
926+
{
927+
["^[a-z]+$"] = new OpenApiSchema
928+
{
929+
Type = JsonSchemaType.String
930+
}
931+
}
932+
};
933+
934+
var expected =
935+
"""
936+
{
937+
"type": "object",
938+
"patternProperties": {
939+
"^[a-z]+$": {
940+
"type": "string"
941+
}
942+
}
943+
}
944+
""";
945+
946+
// When
947+
var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1);
948+
949+
// Then
950+
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
951+
}
952+
831953
[Fact]
832954
public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync()
833955
{

0 commit comments

Comments
 (0)