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..1e924cad3 100644
--- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
+++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
@@ -1308,6 +1308,141 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions(
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
}
+ // PatternProperties tests
+ [Theory]
+ [InlineData(OpenApiSpecVersion.OpenApi3_1)]
+ 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();