Skip to content
10 changes: 5 additions & 5 deletions src/Microsoft.OpenApi/Models/OpenApiConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ public static class OpenApiConstants
/// </summary>
public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties";

/// <summary>
/// Extension: x-jsonschema-patternProperties
/// </summary>
public const string PatternPropertiesExtension = "x-jsonschema-patternProperties";

/// <summary>
/// Field: Version
/// </summary>
Expand Down Expand Up @@ -535,11 +540,6 @@ public static class OpenApiConstants
/// </summary>
public const string PatternProperties = "patternProperties";

/// <summary>
/// Extension: x-jsonschema-patternProperties
/// </summary>
public const string PatternPropertiesExtension = "x-jsonschema-patternProperties";

/// <summary>
/// Field: AdditionalProperties
/// </summary>
Expand Down
78 changes: 78 additions & 0 deletions src/Microsoft.OpenApi/Models/OpenApiSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand Down Expand Up @@ -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());
}
Comment on lines +669 to +681
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach is going to seriously degrade performance when we hit the revealing condition (which the performance tests are not). Plus it's not future proof in the sense that properties will evolve from 3.1 to 3.2, and eventually 3.3, etc...

I'd much rather see an IEqualityComparer be implemented, plus that's been a request for this lib for a while, so it'd be a move in the right direction, enable reusability, etc... #414


internal void WriteAsItemsProperties(IOpenApiWriter writer)
{
// type
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down
138 changes: 137 additions & 1 deletion test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IOpenApiSchema>
{
["^[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<string, IOpenApiSchema>
{
["^[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<string, IOpenApiSchema>
{
["^[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()
{
Expand Down Expand Up @@ -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
{
Expand Down