From 1bace6804bc15c22b7665df7c66bed2a4a27bad0 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 07:28:06 -0400 Subject: [PATCH 01/18] fix(library): avoid false circular refs for external schema re-exports Fixes #2872. The false positive came from workspace schema registration preferring item.Value.Id for component aliases. For OpenApiSchemaReference values, reading Id eagerly dereferenced the external target during registration, which triggered "Circular reference detected while resolving schema" for a root schema re-export plus a direct external reference. This faulty behavior traces back to PR #1826, which introduced the item.Value.Id ?? fallback to support JSON Schema identifier-based resolution. Under JSON Schema 2020-12, JSON Pointer still identifies a lexical location in the containing document, but $id establishes the canonical schema resource URI. This change preserves explicit $id alias registration for concrete schemas while always registering component keys for schema references so workspace registration does not resolve refs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/OpenApiWorkspace.cs | 7 +- .../V31Tests/RelativeReferenceTests.cs | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index b29a7890f..3925f7e3d 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -94,8 +94,13 @@ public void RegisterComponents(OpenApiDocument document) foreach (var item in document.Components.Schemas) { if (item.Value == null) continue; - location = item.Value.Id ?? baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key; + location = baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key; RegisterComponent(location, item.Value); + + if (item.Value is not OpenApiSchemaReference && item.Value.Id is string schemaId && schemaId.Length > 0) + { + RegisterComponent(schemaId, item.Value); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index 3cae0c533..9942968e3 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -203,6 +203,75 @@ public async Task ParseLocalReferenceToJsonSchemaResourceWorks() Assert.Equal(JsonSchemaType.Object | JsonSchemaType.Null, schema.Type); } + [Fact] + public async Task ParseExternalSchemaReferencedDirectlyAndReExportedAtRootWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var sharedPath = Path.Join(tempDirectory, "shared.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.1.0 +info: + title: T + version: 1.0.0 +paths: + /a: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + meta: + $ref: './shared.yaml#/Leaf' +components: + schemas: + Leaf: + $ref: './shared.yaml#/Leaf' +"); + + await File.WriteAllTextAsync(sharedPath, + @"Leaf: + type: object + properties: + x: + type: string + y: + type: integer +"); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings); + var responseSchema = result.Document.Paths["/a"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + var metaSchema = responseSchema.Properties["meta"]; + var leafSchema = result.Document.Components.Schemas["Leaf"]; + + Assert.NotNull(result.Document); + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Message.Contains("Circular reference detected while resolving schema", StringComparison.Ordinal)); + Assert.DoesNotContain(result.Diagnostic.Warnings, warning => warning.Message.Contains("Circular reference detected while resolving schema", StringComparison.Ordinal)); + Assert.IsType(metaSchema); + Assert.IsType(leafSchema); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public void ResolveSubSchema_ShouldTraverseKnownKeywords() { From db41a3c952e651f80a6bbdb7d8aac001031e6dd3 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:08:14 -0400 Subject: [PATCH 02/18] feat(library): add missing json schema properties Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Interfaces/IOpenApiSchema.cs | 2 +- .../IOpenApiSchemaMissingProperties.cs | 86 ++++++++ ...IOpenApiSchemaWithUnevaluatedProperties.cs | 3 + .../Models/OpenApiConstants.cs | 99 ++++++++- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 92 +++++++- .../References/OpenApiSchemaReference.cs | 24 ++- src/Microsoft.OpenApi/PublicAPI.Shipped.txt | 2 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 58 +++++ .../Reader/V3/OpenApiSchemaDeserializer.cs | 72 +++++++ .../Reader/V31/OpenApiSchemaDeserializer.cs | 36 ++++ .../V31Tests/OpenApiSchemaTests.cs | 44 ++++ .../V3Tests/OpenApiSchemaTests.cs | 46 ++++ .../Models/OpenApiSchemaTests.cs | 203 ++++++++++++++++-- .../References/OpenApiSchemaReferenceTests.cs | 45 ++++ 14 files changed, 782 insertions(+), 30 deletions(-) create mode 100644 src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs index 6d43a087a..7cb85f100 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs @@ -260,7 +260,7 @@ public interface IOpenApiSchema : IOpenApiDescribedElement, IOpenApiReadOnlyExte /// /// Indicates whether unevaluated properties are allowed. When false, no unevaluated properties are permitted. /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties - /// Only serialized when false and UnevaluatedPropertiesSchema (from IOpenApiSchemaWithUnevaluatedProperties) is null. + /// Only serialized when false and UnevaluatedPropertiesSchema (from IOpenApiSchemaMissingProperties) is null. /// /// /// NOTE: This property differs from the naming pattern of AdditionalPropertiesAllowed for binary compatibility reasons. diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs new file mode 100644 index 000000000..be2d6fbb1 --- /dev/null +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; + +namespace Microsoft.OpenApi; + +/// +/// Compatibility interface for schema properties that cannot be added to +/// in the current major version without a breaking change. +/// This interface provides access to those properties in contexts where callers need a typed model surface. +/// +/// +/// TODO: Remove this interface in the next major version and merge its content into IOpenApiSchema. +/// +public interface IOpenApiSchemaMissingProperties +{ + /// + /// $anchor - identifies a plain-name location-independent fragment within the schema resource. + /// + public string? Anchor { get; } + + /// + /// Indicates whether unevaluated properties are allowed. When false, no unevaluated properties are permitted. + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties + /// Only serialized when false and is null. + /// + /// + /// NOTE: This property differs from the naming pattern of AdditionalPropertiesAllowed for binary compatibility reasons. + /// In the next major version, this will be renamed to UnevaluatedPropertiesAllowed. + /// TODO: Rename to UnevaluatedPropertiesAllowed in the next major version. + /// + public bool UnevaluatedProperties { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties + /// This is a schema that unevaluated properties must validate against. + /// When serialized, this takes precedence over the boolean property. + /// + /// + /// NOTE: This property differs from the naming pattern of AdditionalProperties/AdditionalPropertiesAllowed + /// for binary compatibility reasons. In the next major version: + /// - This property will be renamed to UnevaluatedProperties + /// - The current boolean UnevaluatedProperties property will be renamed to UnevaluatedPropertiesAllowed + /// + /// TODO: Rename this property to UnevaluatedProperties in the next major version. + /// + public IOpenApiSchema? UnevaluatedPropertiesSchema { get; } + + /// + /// contentEncoding - identifies the encoding of string content. + /// + public string? ContentEncoding { get; } + + /// + /// contentMediaType - identifies the media type of string content. + /// + public string? ContentMediaType { get; } + + /// + /// contentSchema - provides a schema that describes the decoded string content. + /// + public IOpenApiSchema? ContentSchema { get; } + + /// + /// propertyNames - provides a schema that validates property names. + /// + public IOpenApiSchema? PropertyNames { get; } + + /// + /// dependentSchemas - maps property names to schemas that are applied when that property is present. + /// + public IDictionary? DependentSchemas { get; } + + /// + /// if - applies a conditional schema that determines whether or should be evaluated. + /// + public IOpenApiSchema? If { get; } + + /// + /// then - applies when evaluates successfully. + /// + public IOpenApiSchema? Then { get; } + + /// + /// else - applies when does not evaluate successfully. + /// + public IOpenApiSchema? Else { get; } +} diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs index 3379a0837..6fe7e9f9a 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs @@ -1,3 +1,5 @@ +using System; + namespace Microsoft.OpenApi; /// @@ -13,6 +15,7 @@ namespace Microsoft.OpenApi; /// /// TODO: Remove this interface in the next major version and merge its content into IOpenApiSchema. /// +[Obsolete("Use IOpenApiSchemaMissingProperties instead.")] public interface IOpenApiSchemaWithUnevaluatedProperties { /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 5fdf13d12..164e31d77 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -85,6 +85,11 @@ public static class OpenApiConstants /// public const string Vocabulary = "$vocabulary"; + /// + /// Field: Anchor + /// + public const string Anchor = "$anchor"; + /// /// Field: DynamicRef /// @@ -126,9 +131,14 @@ public static class OpenApiConstants public const string UnevaluatedProperties = "unevaluatedProperties"; /// - /// Extension: x-jsonschema-unevaluatedProperties + /// Extension: x-oai-unevaluatedProperties + /// + public const string UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; + + /// + /// Legacy extension: x-jsonschema-unevaluatedProperties /// - public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; + public const string LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; /// /// Field: Version @@ -495,11 +505,51 @@ public static class OpenApiConstants /// public const string PatternProperties = "patternProperties"; + /// + /// Field: PropertyNames + /// + public const string PropertyNames = "propertyNames"; + /// /// Extension: x-jsonschema-patternProperties /// public const string PatternPropertiesExtension = "x-jsonschema-patternProperties"; + /// + /// Field: DependentSchemas + /// + public const string DependentSchemas = "dependentSchemas"; + + /// + /// Field: If + /// + public const string If = "if"; + + /// + /// Field: Then + /// + public const string Then = "then"; + + /// + /// Field: Else + /// + public const string Else = "else"; + + /// + /// Field: ContentEncoding + /// + public const string ContentEncoding = "contentEncoding"; + + /// + /// Field: ContentMediaType + /// + public const string ContentMediaType = "contentMediaType"; + + /// + /// Field: ContentSchema + /// + public const string ContentSchema = "contentSchema"; + /// /// Field: AdditionalProperties /// @@ -735,6 +785,51 @@ public static class OpenApiConstants /// public const string DependentRequired = "dependentRequired"; + /// + /// Extension: x-oai-$anchor + /// + public const string AnchorExtension = "x-oai-$anchor"; + + /// + /// Extension: x-oai-propertyNames + /// + public const string PropertyNamesExtension = "x-oai-propertyNames"; + + /// + /// Extension: x-oai-dependentSchemas + /// + public const string DependentSchemasExtension = "x-oai-dependentSchemas"; + + /// + /// Extension: x-oai-if + /// + public const string IfExtension = "x-oai-if"; + + /// + /// Extension: x-oai-then + /// + public const string ThenExtension = "x-oai-then"; + + /// + /// Extension: x-oai-else + /// + public const string ElseExtension = "x-oai-else"; + + /// + /// Extension: x-oai-contentEncoding + /// + public const string ContentEncodingExtension = "x-oai-contentEncoding"; + + /// + /// Extension: x-oai-contentMediaType + /// + public const string ContentMediaTypeExtension = "x-oai-contentMediaType"; + + /// + /// Extension: x-oai-contentSchema + /// + public const string ContentSchemaExtension = "x-oai-contentSchema"; + #region V2.0 /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 2c967254c..7ff32c10d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -9,6 +9,7 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// The Schema Object allows the definition of input and output data types. /// @@ -18,7 +19,7 @@ namespace Microsoft.OpenApi /// - Serialization: To produce something functionally equivalent to boolean schemas, create an empty /// for "true" behavior, or create a schema with only set to an empty schema for "false" behavior. /// - public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer + public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer { /// public string? Title { get; set; } @@ -44,6 +45,9 @@ public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaW /// public IDictionary? Definitions { get; set; } + /// + public string? Anchor { get; set; } + private string? _exclusiveMaximum; /// public string? ExclusiveMaximum @@ -243,6 +247,30 @@ public string? Minimum /// public IOpenApiSchema? UnevaluatedPropertiesSchema { get; set; } + /// + public string? ContentEncoding { get; set; } + + /// + public string? ContentMediaType { get; set; } + + /// + public IOpenApiSchema? ContentSchema { get; set; } + + /// + public IOpenApiSchema? PropertyNames { get; set; } + + /// + public IDictionary? DependentSchemas { get; set; } + + /// + public IOpenApiSchema? If { get; set; } + + /// + public IOpenApiSchema? Then { get; set; } + + /// + public IOpenApiSchema? Else { get; set; } + /// public OpenApiExternalDocs? ExternalDocs { get; set; } @@ -282,13 +310,32 @@ internal OpenApiSchema(IOpenApiSchema schema) Schema = schema.Schema ?? Schema; Comment = schema.Comment ?? Comment; Vocabulary = schema.Vocabulary != null ? new Dictionary(schema.Vocabulary) : null; + if (schema is IOpenApiSchemaMissingProperties { Anchor: not null } missingPropertiesWithAnchor) + { + Anchor = missingPropertiesWithAnchor.Anchor; + } DynamicAnchor = schema.DynamicAnchor ?? DynamicAnchor; DynamicRef = schema.DynamicRef ?? DynamicRef; Definitions = schema.Definitions != null ? new Dictionary(schema.Definitions) : null; - UnevaluatedProperties = schema.UnevaluatedProperties; - if (schema is IOpenApiSchemaWithUnevaluatedProperties { UnevaluatedPropertiesSchema: { } unevaluatedSchema }) + if (schema is IOpenApiSchemaMissingProperties missingProperties) { - UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); + UnevaluatedProperties = missingProperties.UnevaluatedProperties; + if (missingProperties.UnevaluatedPropertiesSchema is { } unevaluatedSchema) + { + UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); + } + ContentEncoding = missingProperties.ContentEncoding ?? ContentEncoding; + ContentMediaType = missingProperties.ContentMediaType ?? ContentMediaType; + ContentSchema = missingProperties.ContentSchema?.CreateShallowCopy(); + PropertyNames = missingProperties.PropertyNames?.CreateShallowCopy(); + DependentSchemas = missingProperties.DependentSchemas != null ? new Dictionary(missingProperties.DependentSchemas) : null; + If = missingProperties.If?.CreateShallowCopy(); + Then = missingProperties.Then?.CreateShallowCopy(); + Else = missingProperties.Else?.CreateShallowCopy(); + } + else + { + UnevaluatedProperties = schema.UnevaluatedProperties; } ExclusiveMaximum = schema.ExclusiveMaximum ?? ExclusiveMaximum; ExclusiveMinimum = schema.ExclusiveMinimum ?? ExclusiveMinimum; @@ -552,18 +599,22 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // Skip when type is explicitly set to a non-object type (array, string, number, integer, boolean, null). if (!Type.HasValue || (Type.Value & JsonSchemaType.Object) != 0) { + var unevaluatedPropertiesExtensionName = version == OpenApiSpecVersion.OpenApi3_0 + ? OpenApiConstants.UnevaluatedPropertiesExtension + : OpenApiConstants.LegacyUnevaluatedPropertiesExtension; + // Write UnevaluatedPropertiesSchema as extension if present if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - OpenApiConstants.UnevaluatedPropertiesExtension, + unevaluatedPropertiesExtensionName, UnevaluatedPropertiesSchema, callback); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); + writer.WritePropertyName(unevaluatedPropertiesExtensionName); writer.WriteValue(false); } } @@ -573,6 +624,8 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version { writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback); } + + WriteV3CompatibilityKeywords(writer, callback); } // extensions @@ -602,6 +655,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteProperty(OpenApiConstants.Const, Const); writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w)); + writer.WriteProperty(OpenApiConstants.Anchor, Anchor); writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); @@ -625,6 +679,27 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (nodeWriter, s) => nodeWriter.WriteAny(s)); writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, (w, s) => s.SerializeAsV31(w)); writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); + writer.WriteProperty(OpenApiConstants.ContentEncoding, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaType, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.If, If, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.Then, Then, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.Else, Else, (w, s) => s.SerializeAsV31(w)); + } + + private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action callback) + { + writer.WriteProperty(OpenApiConstants.AnchorExtension, Anchor); + writer.WriteProperty(OpenApiConstants.ContentEncodingExtension, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaTypeExtension, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchemaExtension, ContentSchema, callback); + writer.WriteOptionalObject(OpenApiConstants.PropertyNamesExtension, PropertyNames, callback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemasExtension, DependentSchemas, callback); + writer.WriteOptionalObject(OpenApiConstants.IfExtension, If, callback); + writer.WriteOptionalObject(OpenApiConstants.ThenExtension, Then, callback); + writer.WriteOptionalObject(OpenApiConstants.ElseExtension, Else, callback); } internal void WriteAsItemsProperties(IOpenApiWriter writer) @@ -794,6 +869,7 @@ private void SerializeAsV2( // oneOf (Not Supported in V2) - Write the first schema only as an allOf. writer.WriteOptionalCollection(OpenApiConstants.AllOf, OneOf?.Take(1), (w, s) => s.SerializeAsV2(w)); } + #pragma warning restore CS0618 } // properties @@ -850,14 +926,14 @@ private void SerializeAsV2( if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - OpenApiConstants.UnevaluatedPropertiesExtension, + OpenApiConstants.LegacyUnevaluatedPropertiesExtension, UnevaluatedPropertiesSchema, (w, s) => s.SerializeAsV2(w)); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); + writer.WritePropertyName(OpenApiConstants.LegacyUnevaluatedPropertiesExtension); writer.WriteValue(false); } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 104f4791b..6361ade93 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -7,10 +7,11 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// Schema reference object /// - public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible + public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible { /// @@ -62,6 +63,8 @@ public string? Title /// public IDictionary? Definitions { get => Target?.Definitions; } /// + public string? Anchor { get => (Target as IOpenApiSchemaMissingProperties)?.Anchor; } + /// public string? ExclusiveMaximum { get => Target?.ExclusiveMaximum; } /// public string? ExclusiveMinimum { get => Target?.ExclusiveMinimum; } @@ -146,7 +149,23 @@ public IList? Examples /// public bool UnevaluatedProperties { get => Target?.UnevaluatedProperties ?? true; } /// - public IOpenApiSchema? UnevaluatedPropertiesSchema { get => (Target as IOpenApiSchemaWithUnevaluatedProperties)?.UnevaluatedPropertiesSchema; } + public IOpenApiSchema? UnevaluatedPropertiesSchema { get => (Target as IOpenApiSchemaMissingProperties)?.UnevaluatedPropertiesSchema; } + /// + public string? ContentEncoding { get => (Target as IOpenApiSchemaMissingProperties)?.ContentEncoding; } + /// + public string? ContentMediaType { get => (Target as IOpenApiSchemaMissingProperties)?.ContentMediaType; } + /// + public IOpenApiSchema? ContentSchema { get => (Target as IOpenApiSchemaMissingProperties)?.ContentSchema; } + /// + public IOpenApiSchema? PropertyNames { get => (Target as IOpenApiSchemaMissingProperties)?.PropertyNames; } + /// + public IDictionary? DependentSchemas { get => (Target as IOpenApiSchemaMissingProperties)?.DependentSchemas; } + /// + public IOpenApiSchema? If { get => (Target as IOpenApiSchemaMissingProperties)?.If; } + /// + public IOpenApiSchema? Then { get => (Target as IOpenApiSchemaMissingProperties)?.Then; } + /// + public IOpenApiSchema? Else { get => (Target as IOpenApiSchemaMissingProperties)?.Else; } /// public OpenApiExternalDocs? ExternalDocs { get => Target?.ExternalDocs; } /// @@ -220,5 +239,6 @@ protected override JsonSchemaReference CopyReference(JsonSchemaReference sourceR { return new JsonSchemaReference(sourceReference); } + #pragma warning restore CS0618 } } diff --git a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt index 2ad2d72f9..6c8537c04 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt @@ -1890,7 +1890,7 @@ virtual Microsoft.OpenApi.OpenApiWriterBase.WriteValue(System.DateTimeOffset val virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV2(Microsoft.OpenApi.IOpenApiWriter! writer) -> void virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV3(Microsoft.OpenApi.IOpenApiWriter! writer) -> void virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void -const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! Microsoft.OpenApi.IOpenApiSchemaWithUnevaluatedProperties Microsoft.OpenApi.IOpenApiSchemaWithUnevaluatedProperties.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? Microsoft.OpenApi.OpenApiSchema.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..2c1dde11f 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,59 @@ #nullable enable +const Microsoft.OpenApi.OpenApiConstants.Anchor = "$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-oai-$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncoding = "contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-oai-contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaType = "contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-oai-contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchema = "contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-oai-contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemas = "dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-oai-dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! +const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-oai-else" -> string! +const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! +const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-oai-if" -> string! +const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-oai-propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! +const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-oai-then" -> string! +Microsoft.OpenApi.IOpenApiSchemaMissingProperties +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Anchor.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentEncoding.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentMediaType.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.UnevaluatedProperties.get -> bool +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Anchor.get -> string? +Microsoft.OpenApi.OpenApiSchema.Anchor.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentEncoding.get -> string? +Microsoft.OpenApi.OpenApiSchema.ContentEncoding.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentMediaType.get -> string? +Microsoft.OpenApi.OpenApiSchema.ContentMediaType.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.ContentSchema.set -> void +Microsoft.OpenApi.OpenApiSchema.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.OpenApiSchema.DependentSchemas.set -> void +Microsoft.OpenApi.OpenApiSchema.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Else.set -> void +Microsoft.OpenApi.OpenApiSchema.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.If.set -> void +Microsoft.OpenApi.OpenApiSchema.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.PropertyNames.set -> void +Microsoft.OpenApi.OpenApiSchema.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Then.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Anchor.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentEncoding.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentMediaType.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.OpenApiSchemaReference.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.Then.get -> Microsoft.OpenApi.IOpenApiSchema? diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 0ad8e747c..9fdd69702 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -282,6 +282,78 @@ internal static partial class OpenApiV3Deserializer OpenApiConstants.PatternPropertiesExtension, (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + OpenApiConstants.UnevaluatedPropertiesExtension, + (o, n, t, c) => + { + if (n is JsonValue) + { + var value = n.GetScalarValue(); + if (value is not null) + { + o.UnevaluatedProperties = bool.Parse(value); + } + } + else + { + o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); + } + } + }, + { + OpenApiConstants.LegacyUnevaluatedPropertiesExtension, + (o, n, t, c) => + { + if (n is JsonValue) + { + var value = n.GetScalarValue(); + if (value is not null) + { + o.UnevaluatedProperties = bool.Parse(value); + } + } + else + { + o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); + } + } + }, + { + OpenApiConstants.AnchorExtension, + (o, n, _, _) => o.Anchor = n.GetScalarValue() + }, + { + OpenApiConstants.ContentEncodingExtension, + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + OpenApiConstants.ContentMediaTypeExtension, + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + OpenApiConstants.ContentSchemaExtension, + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.PropertyNamesExtension, + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.DependentSchemasExtension, + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.IfExtension, + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ThenExtension, + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ElseExtension, + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 14deab765..f4b98234f 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -43,6 +43,10 @@ internal static partial class OpenApiV31Deserializer { "$defs", (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) + }, + { + "$anchor", + (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { "multipleOf", @@ -164,6 +168,18 @@ internal static partial class OpenApiV31Deserializer } } }, + { + "contentEncoding", + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + "contentMediaType", + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + "contentSchema", + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, { "maxProperties", (o, n, _, _) => @@ -249,6 +265,10 @@ internal static partial class OpenApiV31Deserializer "patternProperties", (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + "propertyNames", + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, { "additionalProperties", (o, n, doc, c) => { @@ -356,6 +376,22 @@ internal static partial class OpenApiV31Deserializer o.DependentRequired = n.CreateArrayMap((n2, _) => n2.GetScalarValue()!, doc, c); } }, + { + "dependentSchemas", + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + "if", + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + "then", + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + "else", + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index ec66dcbb9..e112eb1ae 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -857,6 +857,50 @@ public void ParseSchemaWithoutUnevaluatedPropertiesDefaultsToTrue() Assert.True(actual.UnevaluatedProperties); // Explicitly verify the default } + [Fact] + public void ParseSchemaWithMissingJsonSchemaProperties() + { + var schema = @"{ + ""$anchor"": ""root"", + ""contentEncoding"": ""base64"", + ""contentMediaType"": ""application/jwt"", + ""contentSchema"": { + ""type"": ""array"" + }, + ""propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""if"": { + ""required"": [""token""] + }, + ""then"": { + ""minProperties"": 1 + }, + ""else"": { + ""maxProperties"": 0 + } +}"; + + var actual = OpenApiModelFactory.Parse(schema, OpenApiSpecVersion.OpenApi3_1, new(), out _); + var missingProperties = Assert.IsAssignableFrom(actual); + + Assert.Equal("root", missingProperties.Anchor); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData("{}")] [InlineData("true")] diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 9b0f0b94b..110e2c342 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -178,6 +178,52 @@ public void ParseDictionarySchemaShouldSucceed() } } + [Fact] + public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() + { + var schemaJson = @"{ + ""x-oai-$anchor"": ""root"", + ""x-oai-unevaluatedProperties"": false, + ""x-oai-contentEncoding"": ""base64"", + ""x-oai-contentMediaType"": ""application/jwt"", + ""x-oai-contentSchema"": { + ""type"": ""array"" + }, + ""x-oai-propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""x-oai-dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""x-oai-if"": { + ""required"": [""token""] + }, + ""x-oai-then"": { + ""minProperties"": 1 + }, + ""x-oai-else"": { + ""maxProperties"": 0 + } +}"; + + var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_0, new(), out _, "json", SettingsFixture.ReaderSettings); + var missingProperties = Assert.IsAssignableFrom(schema); + + Assert.Equal("root", missingProperties.Anchor); + Assert.False(missingProperties.UnevaluatedProperties); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Fact] public void ParseBasicSchemaWithExampleShouldSucceed() { diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 520a05a5a..ae49c0a8f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -522,6 +522,48 @@ public void OpenApiSchemaCopyConstructorWithUnevaluatedPropertiesSchemaSucceeds( Assert.Equal(100, baseSchema.UnevaluatedPropertiesSchema.MaxLength); } + [Fact] + public void OpenApiSchemaCopyConstructorWithMissingPropertiesSucceeds() + { + var baseSchema = new OpenApiSchema + { + Anchor = "root", + UnevaluatedProperties = false, + UnevaluatedPropertiesSchema = new OpenApiSchema { Type = JsonSchemaType.String }, + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actualSchema = Assert.IsType(baseSchema.CreateShallowCopy()); + var actualMissingProperties = Assert.IsAssignableFrom(actualSchema); + + Assert.Equal("root", actualMissingProperties.Anchor); + Assert.False(actualMissingProperties.UnevaluatedProperties); + Assert.NotNull(actualMissingProperties.UnevaluatedPropertiesSchema); + Assert.Equal("base64", actualMissingProperties.ContentEncoding); + Assert.Equal("application/jwt", actualMissingProperties.ContentMediaType); + Assert.NotNull(actualMissingProperties.ContentSchema); + Assert.NotNull(actualMissingProperties.PropertyNames); + Assert.NotNull(actualMissingProperties.DependentSchemas); + Assert.NotNull(actualMissingProperties.If); + Assert.NotNull(actualMissingProperties.Then); + Assert.NotNull(actualMissingProperties.Else); + Assert.NotSame(baseSchema.ContentSchema, actualMissingProperties.ContentSchema); + Assert.NotSame(baseSchema.PropertyNames, actualMissingProperties.PropertyNames); + Assert.NotSame(baseSchema.If, actualMissingProperties.If); + Assert.NotSame(baseSchema.Then, actualMissingProperties.Then); + Assert.NotSame(baseSchema.Else, actualMissingProperties.Else); + } + public static TheoryData SchemaExamples() { return new() @@ -1251,32 +1293,38 @@ public async Task SerializeUnevaluatedPropertiesSchemaTakesPrecedenceOverBoolean Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - [Theory] - [InlineData(OpenApiSpecVersion.OpenApi2_0)] - [InlineData(OpenApiSpecVersion.OpenApi3_0)] - public async Task SerializeUnevaluatedPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version) + [Fact] + public async Task SerializeUnevaluatedPropertiesAsExtensionInV2() { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; - // Given - UnevaluatedProperties should be emitted as extension in versions < 3.1 var schema = new OpenApiSchema { UnevaluatedProperties = false }; - // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); - // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - [Theory] - [InlineData(OpenApiSpecVersion.OpenApi2_0)] - [InlineData(OpenApiSpecVersion.OpenApi3_0)] - public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersions(OpenApiSpecVersion version) + [Fact] + public async Task SerializeUnevaluatedPropertiesAsExtensionInV3() + { + var expected = @"{ ""x-oai-unevaluatedProperties"": false }"; + var schema = new OpenApiSchema + { + UnevaluatedProperties = false + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV2() { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; - // Given - UnevaluatedPropertiesSchema should be emitted as extension in versions < 3.1 var schema = new OpenApiSchema { UnevaluatedPropertiesSchema = new OpenApiSchema @@ -1285,10 +1333,25 @@ public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersio } }; - // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV3() + { + var expected = @"{ ""x-oai-unevaluatedProperties"": { ""type"": ""string"" } }"; + var schema = new OpenApiSchema + { + UnevaluatedPropertiesSchema = new OpenApiSchema + { + Type = JsonSchemaType.String + } + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); - // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } @@ -1311,6 +1374,114 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions( Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + [Fact] + public async Task SerializeMissingPropertiesEmitsJsonSchemaKeywordsInV31() + { + var expected = JsonNode.Parse(""" + { + "$anchor": "root", + "contentEncoding": "base64", + "contentMediaType": "application/jwt", + "contentSchema": { + "type": "array" + }, + "propertyNames": { + "pattern": "^[a-z]+$" + }, + "dependentSchemas": { + "token": { + "type": "string" + } + }, + "if": { + "required": [ + "token" + ] + }, + "then": { + "minProperties": 1 + }, + "else": { + "maxProperties": 0 + } + } + """); + + var schema = new OpenApiSchema + { + Anchor = "root", + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actual = JsonNode.Parse(await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1)); + + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + + [Fact] + public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() + { + var expected = JsonNode.Parse(""" + { + "x-oai-$anchor": "root", + "x-oai-contentEncoding": "base64", + "x-oai-contentMediaType": "application/jwt", + "x-oai-contentSchema": { + "type": "array" + }, + "x-oai-propertyNames": { + "pattern": "^[a-z]+$" + }, + "x-oai-dependentSchemas": { + "token": { + "type": "string" + } + }, + "x-oai-if": { + "required": [ + "token" + ] + }, + "x-oai-then": { + "minProperties": 1 + }, + "x-oai-else": { + "maxProperties": 0 + } + } + """); + + var schema = new OpenApiSchema + { + Anchor = "root", + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actual = JsonNode.Parse(await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0)); + + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + [Theory] [InlineData(JsonSchemaType.Array, "array")] [InlineData(JsonSchemaType.String, "string")] diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs index 5b46950e1..cb9e069ec 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs @@ -119,6 +119,51 @@ public void SchemaReferenceWithoutAnnotationsShouldFallbackToTarget() Assert.Equal("target example", schemaReference.Examples.First()?.GetValue()); } + [Fact] + public void SchemaReferenceExposesMissingPropertiesFromTarget() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + const string referenceId = "targetSchema"; + workingDocument.Components.Schemas = new Dictionary + { + [referenceId] = new OpenApiSchema + { + Anchor = "root", + UnevaluatedProperties = false, + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaReference = new OpenApiSchemaReference(referenceId, workingDocument); + var missingProperties = Assert.IsAssignableFrom(schemaReference); + + Assert.Equal("root", missingProperties.Anchor); + Assert.False(missingProperties.UnevaluatedProperties); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData(true)] [InlineData(false)] From f93d76a4643022fc5d35b3d8ca8078276bbcce4f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:09:17 -0400 Subject: [PATCH 03/18] fix(library): use version-specific schema keyword callbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 22 ++++++------- .../Mocks/OpenApiSchemaSerializationTests.cs | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 7ff32c10d..24179f848 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -446,7 +446,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version if (version == OpenApiSpecVersion.OpenApi3_1) { - WriteJsonSchemaKeywords(writer); + WriteJsonSchemaKeywords(writer, callback); } // title @@ -647,14 +647,14 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) SerializeAsV2(writer: writer, parentRequiredProperties: new HashSet(), propertyName: null); } - internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) + internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action callback) { writer.WriteProperty(OpenApiConstants.Id, Id); writer.WriteProperty(OpenApiConstants.DollarSchema, Schema?.ToString()); writer.WriteProperty(OpenApiConstants.Comment, Comment); writer.WriteProperty(OpenApiConstants.Const, Const); writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); - writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, callback); writer.WriteProperty(OpenApiConstants.Anchor, Anchor); writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); @@ -669,7 +669,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteOptionalObject( OpenApiConstants.UnevaluatedProperties, UnevaluatedPropertiesSchema, - (w, s) => s.SerializeAsV31(w)); + callback); } else if (!UnevaluatedProperties) { @@ -677,16 +677,16 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) } } writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (nodeWriter, s) => nodeWriter.WriteAny(s)); - writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, callback); writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); writer.WriteProperty(OpenApiConstants.ContentEncoding, ContentEncoding); writer.WriteProperty(OpenApiConstants.ContentMediaType, ContentMediaType); - writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.If, If, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.Then, Then, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.Else, Else, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, callback); + writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, callback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, callback); + writer.WriteOptionalObject(OpenApiConstants.If, If, callback); + writer.WriteOptionalObject(OpenApiConstants.Then, Then, callback); + writer.WriteOptionalObject(OpenApiConstants.Else, Else, callback); } private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action callback) diff --git a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs index 8a402fb74..2d9c5f766 100644 --- a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs @@ -45,5 +45,37 @@ public void SerializeAsV3_DoesNotCallV31OrV2Serialization() _xmlMock.Verify(c => c.SerializeAsV2(It.IsAny()), Times.Never, "V2 method should not be called"); _xmlMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never, "V31 method should not be called"); } + + [Fact] + public void SerializeAsV31_UsesV31CallbackForJsonSchemaKeywords() + { + using var stringWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(stringWriter); + var childSchemaMock = new Mock { CallBase = true }; + childSchemaMock.Object.Type = JsonSchemaType.String; + _schema.ContentSchema = childSchemaMock.Object; + + _schema.SerializeAsV31(writer); + + childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.AtLeastOnce); + childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.Never); + childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); + } + + [Fact] + public void SerializeAsV32_UsesV32CallbackForJsonSchemaKeywords() + { + using var stringWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(stringWriter); + var childSchemaMock = new Mock { CallBase = true }; + childSchemaMock.Object.Type = JsonSchemaType.String; + _schema.ContentSchema = childSchemaMock.Object; + + _schema.SerializeAsV32(writer); + + childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.AtLeastOnce); + childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never); + childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); + } } } From 2a799408d20a0e4579a9eb3ee9aaa28d00fec19a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:17:09 -0400 Subject: [PATCH 04/18] docs(library): add json schema spec links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Interfaces/IOpenApiSchemaMissingProperties.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs index be2d6fbb1..a144f751e 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs @@ -14,6 +14,7 @@ public interface IOpenApiSchemaMissingProperties { /// /// $anchor - identifies a plain-name location-independent fragment within the schema resource. + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-anchor /// public string? Anchor { get; } @@ -45,41 +46,49 @@ public interface IOpenApiSchemaMissingProperties public IOpenApiSchema? UnevaluatedPropertiesSchema { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentencoding /// contentEncoding - identifies the encoding of string content. /// public string? ContentEncoding { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentmediatype /// contentMediaType - identifies the media type of string content. /// public string? ContentMediaType { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentschema /// contentSchema - provides a schema that describes the decoded string content. /// public IOpenApiSchema? ContentSchema { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-propertynames /// propertyNames - provides a schema that validates property names. /// public IOpenApiSchema? PropertyNames { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-dependentschemas /// dependentSchemas - maps property names to schemas that are applied when that property is present. /// public IDictionary? DependentSchemas { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-if /// if - applies a conditional schema that determines whether or should be evaluated. /// public IOpenApiSchema? If { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-then /// then - applies when evaluates successfully. /// public IOpenApiSchema? Then { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-else /// else - applies when does not evaluate successfully. /// public IOpenApiSchema? Else { get; } From ec04a7fcd43c296602edc15c197dc49cee112472 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:22:22 -0400 Subject: [PATCH 05/18] fix(library): use x-jsonschema schema extensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/OpenApiConstants.cs | 44 +++++++++---------- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 4 +- src/Microsoft.OpenApi/PublicAPI.Shipped.txt | 2 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 20 ++++----- .../V3Tests/OpenApiSchemaTests.cs | 20 ++++----- .../Models/OpenApiSchemaTests.cs | 22 +++++----- 6 files changed, 56 insertions(+), 56 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 164e31d77..9d4057c09 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -131,14 +131,14 @@ public static class OpenApiConstants public const string UnevaluatedProperties = "unevaluatedProperties"; /// - /// Extension: x-oai-unevaluatedProperties + /// Extension: x-jsonschema-unevaluatedProperties /// - public const string UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; + public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; /// - /// Legacy extension: x-jsonschema-unevaluatedProperties + /// Legacy extension: x-oai-unevaluatedProperties /// - public const string LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; + public const string LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; /// /// Field: Version @@ -786,49 +786,49 @@ public static class OpenApiConstants public const string DependentRequired = "dependentRequired"; /// - /// Extension: x-oai-$anchor + /// Extension: x-jsonschema-$anchor /// - public const string AnchorExtension = "x-oai-$anchor"; + public const string AnchorExtension = "x-jsonschema-$anchor"; /// - /// Extension: x-oai-propertyNames + /// Extension: x-jsonschema-propertyNames /// - public const string PropertyNamesExtension = "x-oai-propertyNames"; + public const string PropertyNamesExtension = "x-jsonschema-propertyNames"; /// - /// Extension: x-oai-dependentSchemas + /// Extension: x-jsonschema-dependentSchemas /// - public const string DependentSchemasExtension = "x-oai-dependentSchemas"; + public const string DependentSchemasExtension = "x-jsonschema-dependentSchemas"; /// - /// Extension: x-oai-if + /// Extension: x-jsonschema-if /// - public const string IfExtension = "x-oai-if"; + public const string IfExtension = "x-jsonschema-if"; /// - /// Extension: x-oai-then + /// Extension: x-jsonschema-then /// - public const string ThenExtension = "x-oai-then"; + public const string ThenExtension = "x-jsonschema-then"; /// - /// Extension: x-oai-else + /// Extension: x-jsonschema-else /// - public const string ElseExtension = "x-oai-else"; + public const string ElseExtension = "x-jsonschema-else"; /// - /// Extension: x-oai-contentEncoding + /// Extension: x-jsonschema-contentEncoding /// - public const string ContentEncodingExtension = "x-oai-contentEncoding"; + public const string ContentEncodingExtension = "x-jsonschema-contentEncoding"; /// - /// Extension: x-oai-contentMediaType + /// Extension: x-jsonschema-contentMediaType /// - public const string ContentMediaTypeExtension = "x-oai-contentMediaType"; + public const string ContentMediaTypeExtension = "x-jsonschema-contentMediaType"; /// - /// Extension: x-oai-contentSchema + /// Extension: x-jsonschema-contentSchema /// - public const string ContentSchemaExtension = "x-oai-contentSchema"; + public const string ContentSchemaExtension = "x-jsonschema-contentSchema"; #region V2.0 diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 24179f848..dfa906dee 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -926,14 +926,14 @@ private void SerializeAsV2( if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - OpenApiConstants.LegacyUnevaluatedPropertiesExtension, + OpenApiConstants.UnevaluatedPropertiesExtension, UnevaluatedPropertiesSchema, (w, s) => s.SerializeAsV2(w)); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(OpenApiConstants.LegacyUnevaluatedPropertiesExtension); + writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); writer.WriteValue(false); } } diff --git a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt index 6c8537c04..2ad2d72f9 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt @@ -1890,7 +1890,7 @@ virtual Microsoft.OpenApi.OpenApiWriterBase.WriteValue(System.DateTimeOffset val virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV2(Microsoft.OpenApi.IOpenApiWriter! writer) -> void virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV3(Microsoft.OpenApi.IOpenApiWriter! writer) -> void virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void -const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! Microsoft.OpenApi.IOpenApiSchemaWithUnevaluatedProperties Microsoft.OpenApi.IOpenApiSchemaWithUnevaluatedProperties.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? Microsoft.OpenApi.OpenApiSchema.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 2c1dde11f..cde35ac89 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1,23 +1,23 @@ #nullable enable const Microsoft.OpenApi.OpenApiConstants.Anchor = "$anchor" -> string! -const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-oai-$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-jsonschema-$anchor" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentEncoding = "contentEncoding" -> string! -const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-oai-contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-jsonschema-contentEncoding" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentMediaType = "contentMediaType" -> string! -const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-oai-contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-jsonschema-contentMediaType" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentSchema = "contentSchema" -> string! -const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-oai-contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-jsonschema-contentSchema" -> string! const Microsoft.OpenApi.OpenApiConstants.DependentSchemas = "dependentSchemas" -> string! -const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-oai-dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-jsonschema-dependentSchemas" -> string! const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! -const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-oai-else" -> string! +const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-jsonschema-else" -> string! const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! -const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-oai-if" -> string! -const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-jsonschema-if" -> string! +const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! -const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-oai-propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-jsonschema-propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! -const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-oai-then" -> string! +const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-jsonschema-then" -> string! Microsoft.OpenApi.IOpenApiSchemaMissingProperties Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Anchor.get -> string? Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentEncoding.get -> string? diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 110e2c342..df2f0d6eb 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -182,28 +182,28 @@ public void ParseDictionarySchemaShouldSucceed() public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() { var schemaJson = @"{ - ""x-oai-$anchor"": ""root"", - ""x-oai-unevaluatedProperties"": false, - ""x-oai-contentEncoding"": ""base64"", - ""x-oai-contentMediaType"": ""application/jwt"", - ""x-oai-contentSchema"": { + ""x-jsonschema-$anchor"": ""root"", + ""x-jsonschema-unevaluatedProperties"": false, + ""x-jsonschema-contentEncoding"": ""base64"", + ""x-jsonschema-contentMediaType"": ""application/jwt"", + ""x-jsonschema-contentSchema"": { ""type"": ""array"" }, - ""x-oai-propertyNames"": { + ""x-jsonschema-propertyNames"": { ""pattern"": ""^[a-z]+$"" }, - ""x-oai-dependentSchemas"": { + ""x-jsonschema-dependentSchemas"": { ""token"": { ""type"": ""string"" } }, - ""x-oai-if"": { + ""x-jsonschema-if"": { ""required"": [""token""] }, - ""x-oai-then"": { + ""x-jsonschema-then"": { ""minProperties"": 1 }, - ""x-oai-else"": { + ""x-jsonschema-else"": { ""maxProperties"": 0 } }"; diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index ae49c0a8f..878ea1aa5 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1310,7 +1310,7 @@ public async Task SerializeUnevaluatedPropertiesAsExtensionInV2() [Fact] public async Task SerializeUnevaluatedPropertiesAsExtensionInV3() { - var expected = @"{ ""x-oai-unevaluatedProperties"": false }"; + var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; var schema = new OpenApiSchema { UnevaluatedProperties = false @@ -1341,7 +1341,7 @@ public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV2() [Fact] public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV3() { - var expected = @"{ ""x-oai-unevaluatedProperties"": { ""type"": ""string"" } }"; + var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; var schema = new OpenApiSchema { UnevaluatedPropertiesSchema = new OpenApiSchema @@ -1433,29 +1433,29 @@ public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() { var expected = JsonNode.Parse(""" { - "x-oai-$anchor": "root", - "x-oai-contentEncoding": "base64", - "x-oai-contentMediaType": "application/jwt", - "x-oai-contentSchema": { + "x-jsonschema-$anchor": "root", + "x-jsonschema-contentEncoding": "base64", + "x-jsonschema-contentMediaType": "application/jwt", + "x-jsonschema-contentSchema": { "type": "array" }, - "x-oai-propertyNames": { + "x-jsonschema-propertyNames": { "pattern": "^[a-z]+$" }, - "x-oai-dependentSchemas": { + "x-jsonschema-dependentSchemas": { "token": { "type": "string" } }, - "x-oai-if": { + "x-jsonschema-if": { "required": [ "token" ] }, - "x-oai-then": { + "x-jsonschema-then": { "minProperties": 1 }, - "x-oai-else": { + "x-jsonschema-else": { "maxProperties": 0 } } From b30182773fd875fb0a55c1a2bf48018910922ead Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:26:35 -0400 Subject: [PATCH 06/18] fix(library): remove unshipped schema extension fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/OpenApiConstants.cs | 5 ----- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 8 ++------ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 1 - .../Reader/V3/OpenApiSchemaDeserializer.cs | 18 ------------------ 4 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 9d4057c09..11b7e3546 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -135,11 +135,6 @@ public static class OpenApiConstants /// public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; - /// - /// Legacy extension: x-oai-unevaluatedProperties - /// - public const string LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; - /// /// Field: Version /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index dfa906dee..25d3cdfc5 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -599,22 +599,18 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // Skip when type is explicitly set to a non-object type (array, string, number, integer, boolean, null). if (!Type.HasValue || (Type.Value & JsonSchemaType.Object) != 0) { - var unevaluatedPropertiesExtensionName = version == OpenApiSpecVersion.OpenApi3_0 - ? OpenApiConstants.UnevaluatedPropertiesExtension - : OpenApiConstants.LegacyUnevaluatedPropertiesExtension; - // Write UnevaluatedPropertiesSchema as extension if present if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - unevaluatedPropertiesExtensionName, + OpenApiConstants.UnevaluatedPropertiesExtension, UnevaluatedPropertiesSchema, callback); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(unevaluatedPropertiesExtensionName); + writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); writer.WriteValue(false); } } diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index cde35ac89..30d861e31 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -13,7 +13,6 @@ const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-jsonschema-else" -> string! const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-jsonschema-if" -> string! -const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-jsonschema-propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 9fdd69702..12eb631ea 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -300,24 +300,6 @@ internal static partial class OpenApiV3Deserializer } } }, - { - OpenApiConstants.LegacyUnevaluatedPropertiesExtension, - (o, n, t, c) => - { - if (n is JsonValue) - { - var value = n.GetScalarValue(); - if (value is not null) - { - o.UnevaluatedProperties = bool.Parse(value); - } - } - else - { - o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); - } - } - }, { OpenApiConstants.AnchorExtension, (o, n, _, _) => o.Anchor = n.GetScalarValue() From 407dc794809c99a56f16fd97cd753ce6c12177e0 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:42:15 -0400 Subject: [PATCH 07/18] chore(library): use constants for new schema keywords Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Reader/V31/OpenApiSchemaDeserializer.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index f4b98234f..2c309d63e 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -45,7 +45,7 @@ internal static partial class OpenApiV31Deserializer (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) }, { - "$anchor", + OpenApiConstants.Anchor, (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { @@ -169,15 +169,15 @@ internal static partial class OpenApiV31Deserializer } }, { - "contentEncoding", + OpenApiConstants.ContentEncoding, (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() }, { - "contentMediaType", + OpenApiConstants.ContentMediaType, (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() }, { - "contentSchema", + OpenApiConstants.ContentSchema, (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) }, { @@ -266,7 +266,7 @@ internal static partial class OpenApiV31Deserializer (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, { - "propertyNames", + OpenApiConstants.PropertyNames, (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) }, { @@ -377,19 +377,19 @@ internal static partial class OpenApiV31Deserializer } }, { - "dependentSchemas", + OpenApiConstants.DependentSchemas, (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) }, { - "if", + OpenApiConstants.If, (o, n, doc, c) => o.If = LoadSchema(n, doc, c) }, { - "then", + OpenApiConstants.Then, (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) }, { - "else", + OpenApiConstants.Else, (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) }, }; From 3951a310da6056fd7a6852fda129a8ad8583639a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 13:50:51 -0400 Subject: [PATCH 08/18] fix(library): always copy unevaluated properties Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 25d3cdfc5..c2ba05170 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -317,9 +317,9 @@ internal OpenApiSchema(IOpenApiSchema schema) DynamicAnchor = schema.DynamicAnchor ?? DynamicAnchor; DynamicRef = schema.DynamicRef ?? DynamicRef; Definitions = schema.Definitions != null ? new Dictionary(schema.Definitions) : null; + UnevaluatedProperties = schema.UnevaluatedProperties; if (schema is IOpenApiSchemaMissingProperties missingProperties) { - UnevaluatedProperties = missingProperties.UnevaluatedProperties; if (missingProperties.UnevaluatedPropertiesSchema is { } unevaluatedSchema) { UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); @@ -333,10 +333,6 @@ internal OpenApiSchema(IOpenApiSchema schema) Then = missingProperties.Then?.CreateShallowCopy(); Else = missingProperties.Else?.CreateShallowCopy(); } - else - { - UnevaluatedProperties = schema.UnevaluatedProperties; - } ExclusiveMaximum = schema.ExclusiveMaximum ?? ExclusiveMaximum; ExclusiveMinimum = schema.ExclusiveMinimum ?? ExclusiveMinimum; if (schema is OpenApiSchema eMSchema) From 5360b4a06eb96b64f19fa6621149ae0b142d8177 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 13:54:31 -0400 Subject: [PATCH 09/18] test(library): consolidate unevaluated properties extension tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/OpenApiSchemaTests.cs | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 878ea1aa5..e8365ad4e 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1293,22 +1293,10 @@ public async Task SerializeUnevaluatedPropertiesSchemaTakesPrecedenceOverBoolean Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - [Fact] - public async Task SerializeUnevaluatedPropertiesAsExtensionInV2() - { - var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; - var schema = new OpenApiSchema - { - UnevaluatedProperties = false - }; - - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); - - Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); - } - - [Fact] - public async Task SerializeUnevaluatedPropertiesAsExtensionInV3() + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task SerializeUnevaluatedPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version) { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; var schema = new OpenApiSchema @@ -1316,30 +1304,15 @@ public async Task SerializeUnevaluatedPropertiesAsExtensionInV3() UnevaluatedProperties = false }; - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); - - Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); - } - - [Fact] - public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV2() - { - var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; - var schema = new OpenApiSchema - { - UnevaluatedPropertiesSchema = new OpenApiSchema - { - Type = JsonSchemaType.String - } - }; - - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + var actual = await schema.SerializeAsJsonAsync(version); Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - [Fact] - public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV3() + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersions(OpenApiSpecVersion version) { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; var schema = new OpenApiSchema @@ -1350,7 +1323,7 @@ public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV3() } }; - var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + var actual = await schema.SerializeAsJsonAsync(version); Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } From fadb42225561e150aa99459c0c359c00cf52fb1a Mon Sep 17 00:00:00 2001 From: Romain Vergnory Date: Tue, 9 Jun 2026 18:26:47 +0200 Subject: [PATCH 10/18] feat: add contains/minContains/maxContains members --- .../IOpenApiSchemaWithContainsProperties.cs | 31 ++++++ .../Models/OpenApiConstants.cs | 15 +++ src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 26 ++++- .../References/OpenApiSchemaReference.cs | 8 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 16 +++ .../Reader/V31/OpenApiSchemaDeserializer.cs | 26 +++++ .../V31Tests/OpenApiSchemaTests.cs | 8 +- .../Samples/OpenApiSchema/jsonSchema.json | 7 +- .../Models/OpenApiSchemaTests.cs | 104 ++++++++++++++++++ 9 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs new file mode 100644 index 000000000..2aa91d0d6 --- /dev/null +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs @@ -0,0 +1,31 @@ +namespace Microsoft.OpenApi; + +/// +/// Compatibility interface for the JSON Schema 2020-12 "contains" keywords support. +/// This interface provides access to the Contains, MaxContains and MinContains properties, which were +/// missed in the initial release of the IOpenApiSchema interface. +/// +/// This is a temporary compatibility solution. In the next major version this interface should be +/// merged into IOpenApiSchema. +/// +public interface IOpenApiSchemaWithContainsProperties +{ + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-contains + /// An array instance is valid against "contains" if at least one of its elements is valid against this schema. + /// Inline or referenced schema MUST be of a Schema Object and not a standard JSON Schema. + /// + IOpenApiSchema? Contains { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation + /// The number of elements matching the "contains" schema MUST be less than or equal to this value. + /// + uint? MaxContains { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation + /// The number of elements matching the "contains" schema MUST be greater than or equal to this value. + /// + uint? MinContains { get; } +} diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 11b7e3546..3a8441e37 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -450,6 +450,21 @@ public static class OpenApiConstants /// public const string UniqueItems = "uniqueItems"; + /// + /// Field: Contains + /// + public const string Contains = "contains"; + + /// + /// Field: MaxContains + /// + public const string MaxContains = "maxContains"; + + /// + /// Field: MinContains + /// + public const string MinContains = "minContains"; + /// /// Field: MaxProperties /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index c2ba05170..c0764ddcc 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -19,7 +19,7 @@ namespace Microsoft.OpenApi /// - Serialization: To produce something functionally equivalent to boolean schemas, create an empty /// for "true" behavior, or create a schema with only set to an empty schema for "false" behavior. /// - public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer + public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiSchemaWithContainsProperties, IMetadataContainer { /// public string? Title { get; set; } @@ -211,6 +211,15 @@ public string? Minimum /// public bool? UniqueItems { get; set; } + /// + public IOpenApiSchema? Contains { get; set; } + + /// + public uint? MaxContains { get; set; } + + /// + public uint? MinContains { get; set; } + /// public IDictionary? Properties { get; set; } @@ -361,6 +370,12 @@ internal OpenApiSchema(IOpenApiSchema schema) MaxItems = schema.MaxItems ?? MaxItems; MinItems = schema.MinItems ?? MinItems; UniqueItems = schema.UniqueItems ?? UniqueItems; + if (schema is IOpenApiSchemaWithContainsProperties containsSchema) + { + Contains = containsSchema.Contains?.CreateShallowCopy(); + MaxContains = containsSchema.MaxContains ?? MaxContains; + MinContains = containsSchema.MinContains ?? MinContains; + } Properties = schema.Properties != null ? new Dictionary(schema.Properties) : null; PatternProperties = schema.PatternProperties != null ? new Dictionary(schema.PatternProperties) : null; MaxProperties = schema.MaxProperties ?? MaxProperties; @@ -679,6 +694,15 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action s.SerializeAsV31(w)); + + // maxContains + writer.WriteProperty(OpenApiConstants.MaxContains, MaxContains); + + // minContains + writer.WriteProperty(OpenApiConstants.MinContains, MinContains); } private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action callback) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 6361ade93..08a0baa82 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -11,7 +11,7 @@ namespace Microsoft.OpenApi /// /// Schema reference object /// - public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible + public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiSchemaWithContainsProperties, IOpenApiExtensible { /// @@ -123,6 +123,12 @@ public bool WriteOnly /// public bool? UniqueItems { get => Target?.UniqueItems; } /// + public IOpenApiSchema? Contains { get => (Target as IOpenApiSchemaWithContainsProperties)?.Contains; } + /// + public uint? MaxContains { get => (Target as IOpenApiSchemaWithContainsProperties)?.MaxContains; } + /// + public uint? MinContains { get => (Target as IOpenApiSchemaWithContainsProperties)?.MinContains; } + /// public IDictionary? Properties { get => Target?.Properties; } /// public IDictionary? PatternProperties { get => Target?.PatternProperties; } diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 30d861e31..6aa4a87be 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -56,3 +56,19 @@ Microsoft.OpenApi.OpenApiSchemaReference.Else.get -> Microsoft.OpenApi.IOpenApiS Microsoft.OpenApi.OpenApiSchemaReference.If.get -> Microsoft.OpenApi.IOpenApiSchema? Microsoft.OpenApi.OpenApiSchemaReference.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? Microsoft.OpenApi.OpenApiSchemaReference.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +const Microsoft.OpenApi.OpenApiConstants.Contains = "contains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MaxContains = "maxContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MinContains = "minContains" -> string! +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties.MaxContains.get -> uint? +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Contains.set -> void +Microsoft.OpenApi.OpenApiSchema.MaxContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.MaxContains.set -> void +Microsoft.OpenApi.OpenApiSchema.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.MinContains.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.MaxContains.get -> uint? +Microsoft.OpenApi.OpenApiSchemaReference.MinContains.get -> uint? diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 2c309d63e..7a6e9318c 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -148,6 +148,32 @@ internal static partial class OpenApiV31Deserializer } } }, + { + "contains", + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + "maxContains", + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + "minContains", + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, { "unevaluatedProperties", (o, n, t, c) => diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index e112eb1ae..db7488904 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -45,7 +45,13 @@ public async Task ParseBasicV31SchemaShouldSucceed() Items = new OpenApiSchema { Type = JsonSchemaType.String - } + }, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String + }, + MinContains = 1, + MaxContains = 5 }, ["vegetables"] = new OpenApiSchema { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json index 4a16ab4f5..4ee9fc8fa 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json @@ -8,7 +8,12 @@ "type": "array", "items": { "type": "string" - } + }, + "contains": { + "type": "string" + }, + "minContains": 1, + "maxContains": 5 }, "vegetables": { "type": "array" diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index e8365ad4e..732449036 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -564,6 +564,41 @@ public void OpenApiSchemaCopyConstructorWithMissingPropertiesSucceeds() Assert.NotSame(baseSchema.Else, actualMissingProperties.Else); } + [Fact] + public void OpenApiSchemaCopyConstructorWithContainsSucceeds() + { + var baseSchema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String, + MaxLength = 100 + }, + MinContains = 1, + MaxContains = 5 + }; + + var actualSchema = Assert.IsType(baseSchema.CreateShallowCopy()); + + // Verify scalar properties are copied + Assert.Equal(baseSchema.MinContains, actualSchema.MinContains); + Assert.Equal(baseSchema.MaxContains, actualSchema.MaxContains); + + // Verify schema property is copied + Assert.NotNull(actualSchema.Contains); + Assert.Equal(JsonSchemaType.String, actualSchema.Contains.Type); + Assert.Equal(100, actualSchema.Contains.MaxLength); + + // Verify it's a shallow copy (different object reference) + Assert.NotSame(baseSchema.Contains, actualSchema.Contains); + + // Verify that changing the copy doesn't affect the original + var actualContainsTyped = Assert.IsType(actualSchema.Contains); + actualContainsTyped.MaxLength = 200; + Assert.Equal(100, baseSchema.Contains.MaxLength); + } + public static TheoryData SchemaExamples() { return new() @@ -1202,6 +1237,75 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); } + [Fact] + public async Task SerializeContainsKeywordsAsV31Works() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 1, + MaxContains = 5 + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV31(writer); + await writer.FlushAsync(); + + var v31Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV31Schema = + """ + { + "type": "array", + "contains": { + "type": "string" + }, + "maxContains": 5, + "minContains": 1 + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV31Schema), JsonNode.Parse(v31Schema))); + } + + [Fact] + public async Task SerializeContainsKeywordsAsV3DoesNotEmit() + { + // Arrange - contains/minContains/maxContains are JSON Schema 2020-12 keywords and have no equivalent in OpenAPI 3.0 + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 1, + MaxContains = 5 + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "type": "array" + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + // UnevaluatedProperties tests - similar to AdditionalProperties pattern [Fact] public async Task SerializeUnevaluatedPropertiesBooleanDefaultDoesNotEmit() From fc40df977068cea163ff936f3a918deaab762751 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 14:52:01 -0400 Subject: [PATCH 11/18] chore(library): use schema contains constants in readers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Reader/V31/OpenApiSchemaDeserializer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 7a6e9318c..962ad5283 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -149,11 +149,11 @@ internal static partial class OpenApiV31Deserializer } }, { - "contains", + OpenApiConstants.Contains, (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) }, { - "maxContains", + OpenApiConstants.MaxContains, (o, n, _, _) => { var maxContains = n.GetScalarValue(); @@ -164,7 +164,7 @@ internal static partial class OpenApiV31Deserializer } }, { - "minContains", + OpenApiConstants.MinContains, (o, n, _, _) => { var minContains = n.GetScalarValue(); From fffc5111e3dadf5d2580ed5c3b3798c2ca58b007 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 14:55:43 -0400 Subject: [PATCH 12/18] chore(library): serialize contains keywords as v3 extensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/OpenApiConstants.cs | 15 +++++++++++++++ src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 3 +++ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 3 +++ .../Models/OpenApiSchemaTests.cs | 18 +++++++++++++++--- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 3a8441e37..3a24bfb60 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -840,6 +840,21 @@ public static class OpenApiConstants /// public const string ContentSchemaExtension = "x-jsonschema-contentSchema"; + /// + /// Extension: x-jsonschema-contains + /// + public const string ContainsExtension = "x-jsonschema-contains"; + + /// + /// Extension: x-jsonschema-maxContains + /// + public const string MaxContainsExtension = "x-jsonschema-maxContains"; + + /// + /// Extension: x-jsonschema-minContains + /// + public const string MinContainsExtension = "x-jsonschema-minContains"; + #region V2.0 /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index c0764ddcc..2d9448715 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -711,6 +711,9 @@ private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action string! const Microsoft.OpenApi.OpenApiConstants.ContentSchema = "contentSchema" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-jsonschema-contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContainsExtension = "x-jsonschema-contains" -> string! const Microsoft.OpenApi.OpenApiConstants.DependentSchemas = "dependentSchemas" -> string! const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-jsonschema-dependentSchemas" -> string! const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-jsonschema-else" -> string! const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-jsonschema-if" -> string! +const Microsoft.OpenApi.OpenApiConstants.MaxContainsExtension = "x-jsonschema-maxContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MinContainsExtension = "x-jsonschema-minContains" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-jsonschema-propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 732449036..b83a47101 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1275,9 +1275,8 @@ public async Task SerializeContainsKeywordsAsV31Works() } [Fact] - public async Task SerializeContainsKeywordsAsV3DoesNotEmit() + public async Task SerializeContainsKeywordsAsV3EmitsCompatibilityExtensions() { - // Arrange - contains/minContains/maxContains are JSON Schema 2020-12 keywords and have no equivalent in OpenAPI 3.0 var schema = new OpenApiSchema { Type = JsonSchemaType.Array, @@ -1298,7 +1297,12 @@ public async Task SerializeContainsKeywordsAsV3DoesNotEmit() var expectedV3Schema = """ { - "type": "array" + "type": "array", + "x-jsonschema-contains": { + "type": "string" + }, + "x-jsonschema-maxContains": 5, + "x-jsonschema-minContains": 1 } """; @@ -1516,6 +1520,11 @@ public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() "x-jsonschema-contentSchema": { "type": "array" }, + "x-jsonschema-contains": { + "type": "string" + }, + "x-jsonschema-maxContains": 3, + "x-jsonschema-minContains": 1, "x-jsonschema-propertyNames": { "pattern": "^[a-z]+$" }, @@ -1544,6 +1553,9 @@ public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() ContentEncoding = "base64", ContentMediaType = "application/jwt", ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MaxContains = 3, + MinContains = 1, PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, DependentSchemas = new Dictionary { From 1600ea2b25046fb5c9e83c5aed0ea3b395a5303b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 15:15:10 -0400 Subject: [PATCH 13/18] chore(library): add v3 contains extension deserialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Reader/V3/OpenApiSchemaDeserializer.cs | 26 ++++++++++++++ .../V3Tests/OpenApiSchemaTests.cs | 8 +++++ .../Models/OpenApiSchemaTests.cs | 36 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 12eb631ea..6a933fe5f 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -316,6 +316,32 @@ internal static partial class OpenApiV3Deserializer OpenApiConstants.ContentSchemaExtension, (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) }, + { + OpenApiConstants.ContainsExtension, + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.MaxContainsExtension, + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + OpenApiConstants.MinContainsExtension, + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, { OpenApiConstants.PropertyNamesExtension, (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index df2f0d6eb..4e8b80286 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -189,6 +189,11 @@ public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() ""x-jsonschema-contentSchema"": { ""type"": ""array"" }, + ""x-jsonschema-contains"": { + ""type"": ""string"" + }, + ""x-jsonschema-maxContains"": 5, + ""x-jsonschema-minContains"": 1, ""x-jsonschema-propertyNames"": { ""pattern"": ""^[a-z]+$"" }, @@ -216,6 +221,9 @@ public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() Assert.Equal("base64", missingProperties.ContentEncoding); Assert.Equal("application/jwt", missingProperties.ContentMediaType); Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal(JsonSchemaType.String, missingProperties.Contains?.Type); + Assert.Equal((uint?)5, missingProperties.MaxContains); + Assert.Equal((uint?)1, missingProperties.MinContains); Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); Assert.NotNull(missingProperties.If?.Required); diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index b83a47101..8bc270e69 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1811,6 +1811,42 @@ public void DeserializePatternPropertiesExtensionInV3AssignsPatternPropertiesPro Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey("x-jsonschema-patternProperties")); } + [Fact] + public void DeserializeContainsExtensionsInV3AssignsContainsProperties() + { + var jsonContent = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "TestSchema": { + "type": "array", + "x-jsonschema-contains": { + "type": "string" + }, + "x-jsonschema-maxContains": 5, + "x-jsonschema-minContains": 1 + } + } + } + } + """; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + + Assert.Empty(readResult.Diagnostic.Errors); + var schema = readResult.Document.Components.Schemas["TestSchema"]; + var missingProperties = Assert.IsAssignableFrom(schema); + Assert.Equal(JsonSchemaType.String, missingProperties.Contains?.Type); + Assert.Equal((uint?)5, missingProperties.MaxContains); + Assert.Equal((uint?)1, missingProperties.MinContains); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.ContainsExtension)); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MaxContainsExtension)); + Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MinContainsExtension)); + } + internal class SchemaVisitor : OpenApiVisitorBase { public List Titles = new(); From c62e293466a1a8369bba37ee3dc56a86736ee16b Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 15:35:14 -0400 Subject: [PATCH 14/18] test(library): adapt ported schema tests for support v2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../V3Tests/OpenApiSchemaTests.cs | 7 ++++--- .../Mocks/OpenApiSchemaSerializationTests.cs | 17 ----------------- .../Models/OpenApiSchemaTests.cs | 8 ++++---- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 4e8b80286..7465b5e3b 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -215,15 +215,16 @@ public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_0, new(), out _, "json", SettingsFixture.ReaderSettings); var missingProperties = Assert.IsAssignableFrom(schema); + var containsProperties = Assert.IsAssignableFrom(schema); Assert.Equal("root", missingProperties.Anchor); Assert.False(missingProperties.UnevaluatedProperties); Assert.Equal("base64", missingProperties.ContentEncoding); Assert.Equal("application/jwt", missingProperties.ContentMediaType); Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); - Assert.Equal(JsonSchemaType.String, missingProperties.Contains?.Type); - Assert.Equal((uint?)5, missingProperties.MaxContains); - Assert.Equal((uint?)1, missingProperties.MinContains); + Assert.Equal(JsonSchemaType.String, containsProperties.Contains?.Type); + Assert.Equal((uint?)5, containsProperties.MaxContains); + Assert.Equal((uint?)1, containsProperties.MinContains); Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); Assert.NotNull(missingProperties.If?.Required); diff --git a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs index 2d9c5f766..81d796c0f 100644 --- a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs @@ -58,23 +58,6 @@ public void SerializeAsV31_UsesV31CallbackForJsonSchemaKeywords() _schema.SerializeAsV31(writer); childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.AtLeastOnce); - childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.Never); - childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); - } - - [Fact] - public void SerializeAsV32_UsesV32CallbackForJsonSchemaKeywords() - { - using var stringWriter = new StringWriter(); - var writer = new OpenApiJsonWriter(stringWriter); - var childSchemaMock = new Mock { CallBase = true }; - childSchemaMock.Object.Type = JsonSchemaType.String; - _schema.ContentSchema = childSchemaMock.Object; - - _schema.SerializeAsV32(writer); - - childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.AtLeastOnce); - childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never); childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 8bc270e69..c1cc20c54 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1838,10 +1838,10 @@ public void DeserializeContainsExtensionsInV3AssignsContainsProperties() Assert.Empty(readResult.Diagnostic.Errors); var schema = readResult.Document.Components.Schemas["TestSchema"]; - var missingProperties = Assert.IsAssignableFrom(schema); - Assert.Equal(JsonSchemaType.String, missingProperties.Contains?.Type); - Assert.Equal((uint?)5, missingProperties.MaxContains); - Assert.Equal((uint?)1, missingProperties.MinContains); + var containsProperties = Assert.IsAssignableFrom(schema); + Assert.Equal(JsonSchemaType.String, containsProperties.Contains?.Type); + Assert.Equal((uint?)5, containsProperties.MaxContains); + Assert.Equal((uint?)1, containsProperties.MinContains); Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.ContainsExtension)); Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MaxContainsExtension)); Assert.True(schema.Extensions is null || !schema.Extensions.ContainsKey(OpenApiConstants.MinContainsExtension)); From e8fea3ef4d97f4ed882c62908a1ac8bc26d3f173 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 15:35:19 -0400 Subject: [PATCH 15/18] chore(benchmark): refresh performance reports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../performance.Descriptions-report-github.md | 12 ++-- .../performance.Descriptions-report.csv | 12 ++-- .../performance.Descriptions-report.html | 14 ++--- .../performance.Descriptions-report.json | 2 +- .../performance.EmptyModels-report-github.md | 56 +++++++++--------- .../performance.EmptyModels-report.csv | 56 +++++++++--------- .../performance.EmptyModels-report.html | 58 +++++++++---------- .../performance.EmptyModels-report.json | 2 +- 8 files changed, 106 insertions(+), 106 deletions(-) diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md index 282deed48..80c323561 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md @@ -12,9 +12,9 @@ WarmupCount=3 ``` | Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | |------------- |-------------:|--------------:|-------------:|-----------:|-----------:|----------:|-------------:| -| PetStoreYaml | 273.1 μs | 100.13 μs | 5.49 μs | 74.2188 | 15.6250 | - | 305.46 KB | -| PetStoreJson | 107.3 μs | 73.59 μs | 4.03 μs | 41.0156 | 7.3242 | - | 167.6 KB | -| GHESYaml | 579,750.4 μs | 566,587.31 μs | 31,056.56 μs | 44000.0000 | 18000.0000 | 3000.0000 | 250062.54 KB | -| GHESJson | 239,218.6 μs | 182,294.47 μs | 9,992.18 μs | 17000.0000 | 9000.0000 | 2000.0000 | 107234 KB | -| GHESNextYaml | 701,412.2 μs | 280,955.53 μs | 15,400.12 μs | 80000.0000 | 20000.0000 | 3000.0000 | 443588.54 KB | -| GHESNextJson | 388,845.2 μs | 160,463.67 μs | 8,795.56 μs | 51000.0000 | 11000.0000 | 2000.0000 | 305346.16 KB | +| PetStoreYaml | 362.4 μs | 40.82 μs | 2.24 μs | 74.2188 | 15.6250 | - | 307.15 KB | +| PetStoreJson | 151.2 μs | 17.70 μs | 0.97 μs | 41.0156 | 6.8359 | - | 169.29 KB | +| GHESYaml | 772,063.1 μs | 161,793.80 μs | 8,868.46 μs | 45000.0000 | 18000.0000 | 3000.0000 | 253280.85 KB | +| GHESJson | 304,062.4 μs | 99,068.53 μs | 5,430.28 μs | 18000.0000 | 10000.0000 | 2000.0000 | 110452.47 KB | +| GHESNextYaml | 988,379.0 μs | 43,728.33 μs | 2,396.90 μs | 80000.0000 | 19000.0000 | 3000.0000 | 446980.96 KB | +| GHESNextJson | 558,548.3 μs | 292,614.67 μs | 16,039.20 μs | 52000.0000 | 13000.0000 | 3000.0000 | 308740.81 KB | diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv index dca4b396b..16b121fac 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv @@ -1,7 +1,7 @@ Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Gen1,Gen2,Allocated -PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,273.1 μs,100.13 μs,5.49 μs,74.2188,15.6250,0.0000,305.46 KB -PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,107.3 μs,73.59 μs,4.03 μs,41.0156,7.3242,0.0000,167.6 KB -GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"579,750.4 μs","566,587.31 μs","31,056.56 μs",44000.0000,18000.0000,3000.0000,250062.54 KB -GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"239,218.6 μs","182,294.47 μs","9,992.18 μs",17000.0000,9000.0000,2000.0000,107234 KB -GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"701,412.2 μs","280,955.53 μs","15,400.12 μs",80000.0000,20000.0000,3000.0000,443588.54 KB -GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"388,845.2 μs","160,463.67 μs","8,795.56 μs",51000.0000,11000.0000,2000.0000,305346.16 KB +PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,362.4 μs,40.82 μs,2.24 μs,74.2188,15.6250,0.0000,307.15 KB +PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,151.2 μs,17.70 μs,0.97 μs,41.0156,6.8359,0.0000,169.29 KB +GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"772,063.1 μs","161,793.80 μs","8,868.46 μs",45000.0000,18000.0000,3000.0000,253280.85 KB +GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"304,062.4 μs","99,068.53 μs","5,430.28 μs",18000.0000,10000.0000,2000.0000,110452.47 KB +GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"988,379.0 μs","43,728.33 μs","2,396.90 μs",80000.0000,19000.0000,3000.0000,446980.96 KB +GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"558,548.3 μs","292,614.67 μs","16,039.20 μs",52000.0000,13000.0000,3000.0000,308740.81 KB diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html index 0ab30707a..0d32e5521 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html @@ -2,7 +2,7 @@ -performance.Descriptions-20260529-141213 +performance.Descriptions-20260609-152625