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 {