diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs
index a54758002..c244b7c6e 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs
@@ -135,6 +135,11 @@ public static class OpenApiConstants
///
public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties";
+ ///
+ /// Extension: x-jsonschema-patternProperties
+ ///
+ public const string PatternPropertiesExtension = "x-jsonschema-patternProperties";
+
///
/// Field: Version
///
@@ -535,11 +540,6 @@ 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 795ee18ee..502fc638c 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs
@@ -3,6 +3,8 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -493,6 +495,8 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
// properties
writer.WriteOptionalMap(OpenApiConstants.Properties, Properties, callback);
+ var hasPatternPropertiesForV30 = version == OpenApiSpecVersion.OpenApi3_0 && PatternProperties is { Count: > 0 };
+
// additionalProperties
if (AdditionalProperties is not null && version >= OpenApiSpecVersion.OpenApi3_0)
{
@@ -501,6 +505,20 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version
AdditionalProperties,
callback);
}
+ else if (hasPatternPropertiesForV30)
+ {
+ if (TryGetPatternPropertiesFallbackSchema(out var fallbackSchema) && fallbackSchema is not null)
+ {
+ writer.WriteOptionalObject(
+ OpenApiConstants.AdditionalProperties,
+ fallbackSchema,
+ callback);
+ }
+ else
+ {
+ writer.WriteProperty(OpenApiConstants.AdditionalProperties, true);
+ }
+ }
// true is the default, no need to write it out
else if (!AdditionalPropertiesAllowed)
{
@@ -617,6 +635,51 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer)
writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s));
}
+ private bool TryGetPatternPropertiesFallbackSchema(out IOpenApiSchema? fallbackSchema)
+ {
+ fallbackSchema = null;
+ if (PatternProperties is not { Count: > 0 })
+ {
+ return false;
+ }
+
+ fallbackSchema = PatternProperties.First().Value;
+ if (PatternProperties.Count == 1)
+ {
+ return fallbackSchema is not null;
+ }
+
+ var baselineNode = SerializeSchemaToComparableJsonNode(fallbackSchema);
+ if (baselineNode is null)
+ {
+ fallbackSchema = null;
+ return false;
+ }
+
+ if (PatternProperties.Skip(1)
+ .Any(x => SerializeSchemaToComparableJsonNode(x.Value) is not {} schemaNode || !JsonNode.DeepEquals(baselineNode, schemaNode)))
+ {
+ fallbackSchema = null;
+ return false;
+ }
+
+ return true;
+ }
+
+ private static JsonNode? SerializeSchemaToComparableJsonNode(IOpenApiSchema schema)
+ {
+ if (schema is not IOpenApiSerializable serializableSchema)
+ {
+ return null;
+ }
+
+ using var stringWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var jsonWriter = new OpenApiJsonWriter(stringWriter, new OpenApiJsonWriterSettings { Terse = true });
+ serializableSchema.SerializeAsV31(jsonWriter);
+
+ return JsonNode.Parse(stringWriter.ToString());
+ }
+
internal void WriteAsItemsProperties(IOpenApiWriter writer)
{
// type
@@ -795,6 +858,7 @@ private void SerializeAsV2(
s.SerializeAsV2(w);
});
+ var hasPatternProperties = PatternProperties is { Count: > 0 };
// additionalProperties
// true is the default, no need to write it out
if (AdditionalProperties is not null)
@@ -804,6 +868,20 @@ private void SerializeAsV2(
AdditionalProperties,
(w, s) => s.SerializeAsV2(w));
}
+ else if (hasPatternProperties)
+ {
+ if (TryGetPatternPropertiesFallbackSchema(out var fallbackSchema) && fallbackSchema is not null)
+ {
+ writer.WriteOptionalObject(
+ OpenApiConstants.AdditionalProperties,
+ fallbackSchema,
+ (w, s) => s.SerializeAsV2(w));
+ }
+ else
+ {
+ writer.WriteProperty(OpenApiConstants.AdditionalProperties, true);
+ }
+ }
else if (!AdditionalPropertiesAllowed)
{
writer.WriteProperty(OpenApiConstants.AdditionalProperties, AdditionalPropertiesAllowed);
diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
index 3cb5af201..4b37d4f5b 100644
--- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
+++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
@@ -846,6 +846,128 @@ public async Task SerializeAdditionalPropertiesAsV3PlusEmits(OpenApiSpecVersion
Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
}
+ [Fact]
+ public async Task SerializePatternPropertiesAsV3EmitsExtensionAndSchemaFallback()
+ {
+ // Given
+ var schema = new OpenApiSchema
+ {
+ Type = JsonSchemaType.Object,
+ AdditionalPropertiesAllowed = false,
+ PatternProperties = new Dictionary
+ {
+ ["^[a-z][a-z0-9_]*$"] = new OpenApiSchema
+ {
+ Type = JsonSchemaType.Integer,
+ Format = "int32"
+ }
+ }
+ };
+
+ var expected =
+ """
+ {
+ "type": "object",
+ "x-jsonschema-patternProperties": {
+ "^[a-z][a-z0-9_]*$": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ """;
+
+ // When
+ var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
+
+ // Then
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
+ }
+
+ [Fact]
+ public async Task SerializePatternPropertiesAsV3EmitsExtensionAndTrueFallbackWhenSchemasDiffer()
+ {
+ // Given
+ var schema = new OpenApiSchema
+ {
+ Type = JsonSchemaType.Object,
+ PatternProperties = new Dictionary
+ {
+ ["^[a-z]+$"] = new OpenApiSchema
+ {
+ Type = JsonSchemaType.String
+ },
+ ["^[0-9]+$"] = new OpenApiSchema
+ {
+ Type = JsonSchemaType.Integer,
+ Format = "int32"
+ }
+ }
+ };
+
+ var expected =
+ """
+ {
+ "type": "object",
+ "x-jsonschema-patternProperties": {
+ "^[a-z]+$": {
+ "type": "string"
+ },
+ "^[0-9]+$": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "additionalProperties": true
+ }
+ """;
+
+ // When
+ var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
+
+ // Then
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
+ }
+
+ [Fact]
+ public async Task SerializePatternPropertiesAsV31RemainsStandardKeyword()
+ {
+ // Given
+ var schema = new OpenApiSchema
+ {
+ Type = JsonSchemaType.Object,
+ PatternProperties = new Dictionary
+ {
+ ["^[a-z]+$"] = new OpenApiSchema
+ {
+ Type = JsonSchemaType.String
+ }
+ }
+ };
+
+ var expected =
+ """
+ {
+ "type": "object",
+ "patternProperties": {
+ "^[a-z]+$": {
+ "type": "string"
+ }
+ }
+ }
+ """;
+
+ // When
+ var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1);
+
+ // Then
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual)));
+ }
+
[Fact]
public async Task SerializeOneOfWithNullAsV3ShouldUseNullableAsync()
{
@@ -1340,7 +1462,21 @@ public async Task SerializePatternPropertiesAsKeywordInV31AndV32(OpenApiSpecVers
[InlineData(OpenApiSpecVersion.OpenApi3_0)]
public async Task SerializePatternPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version)
{
- var expected = @"{ ""x-jsonschema-patternProperties"": { ""^[a-z]+"": { ""type"": ""string"" } } }";
+ var expected = """
+ {
+ "additionalProperties":
+ {
+ "type": "string"
+ },
+ "x-jsonschema-patternProperties":
+ {
+ "^[a-z]+":
+ {
+ "type": "string"
+ }
+ }
+ }
+ """;
// Given - patternProperties should be emitted as extension in versions < 3.1
var schema = new OpenApiSchema
{