From db41a3c952e651f80a6bbdb7d8aac001031e6dd3 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:08:14 -0400 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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