Skip to content
5 changes: 5 additions & 0 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
71 changes: 71 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,13 @@
// properties
writer.WriteOptionalMap(OpenApiConstants.Properties, Properties, callback);

var hasPatternPropertiesForV30 = version == OpenApiSpecVersion.OpenApi3_0 && PatternProperties is { Count: > 0 };

if (hasPatternPropertiesForV30)
{
writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback);
}

// additionalProperties
if (AdditionalProperties is not null && version >= OpenApiSpecVersion.OpenApi3_0)
{
Expand All @@ -501,6 +510,20 @@
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 @@ -611,6 +634,54 @@
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;
}

foreach (var schema in PatternProperties.Skip(1).Select(static x => x.Value))
{
var schemaNode = SerializeSchemaToComparableJsonNode(schema);
if (schemaNode is null || !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
Copy Markdown
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
1 change: 1 addition & 0 deletions src/Microsoft.OpenApi/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2033,3 +2033,4 @@ Microsoft.OpenApi.OpenApiSchemaReference.UnevaluatedPropertiesSchema.get -> Micr
Microsoft.OpenApi.OpenApiSecurityScheme.OAuth2MetadataUrl.get -> System.Uri?
Microsoft.OpenApi.OpenApiSecurityScheme.OAuth2MetadataUrl.set -> void
Microsoft.OpenApi.OpenApiSecuritySchemeReference.OAuth2MetadataUrl.get -> System.Uri?
const Microsoft.OpenApi.OpenApiConstants.PatternPropertiesExtension = "x-jsonSchema-patternProperties" -> string!
122 changes: 122 additions & 0 deletions test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,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