diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 51187591c..214b13419 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -79,9 +79,21 @@ public string? Title /// public string? Minimum { get => string.IsNullOrEmpty(Reference.Minimum) ? Target?.Minimum : Reference.Minimum; set => Reference.Minimum = value; } /// - public int? MaxLength { get => Reference.MaxLength ?? Target?.MaxLength; set => Reference.MaxLength = value; } + public int? MaxLength + { + get => Target?.MaxLength is { } tMax && Reference.MaxLength is { } rMax + ? Math.Min(tMax, rMax) + : Target?.MaxLength ?? Reference.MaxLength; + set => Reference.MaxLength = value; + } /// - public int? MinLength { get => Reference.MinLength ?? Target?.MinLength; set => Reference.MinLength = value; } + public int? MinLength + { + get => Target?.MinLength is { } tMin && Reference.MinLength is { } rMin + ? Math.Max(tMin, rMin) + : Target?.MinLength ?? Reference.MinLength; + set => Reference.MinLength = value; + } /// public string? Pattern { get => string.IsNullOrEmpty(Reference.Pattern) ? Target?.Pattern : Reference.Pattern; set => Reference.Pattern = value; } /// @@ -95,13 +107,13 @@ public JsonNode? Default /// public bool ReadOnly { - get => Reference.ReadOnly ?? Target?.ReadOnly ?? false; + get => Reference.ReadOnly == true || Target?.ReadOnly == true; set => Reference.ReadOnly = value; } /// public bool WriteOnly { - get => Reference.WriteOnly ?? Target?.WriteOnly ?? false; + get => Reference.WriteOnly == true || Target?.WriteOnly == true; set => Reference.WriteOnly = value; } /// @@ -117,9 +129,21 @@ public bool WriteOnly /// public IOpenApiSchema? Items { get => Reference.Items ?? Target?.Items; set => Reference.Items = value; } /// - public int? MaxItems { get => Reference.MaxItems ?? Target?.MaxItems; set => Reference.MaxItems = value; } + public int? MaxItems + { + get => Target?.MaxItems is { } tMax && Reference.MaxItems is { } rMax + ? Math.Min(tMax, rMax) + : Target?.MaxItems ?? Reference.MaxItems; + set => Reference.MaxItems = value; + } /// - public int? MinItems { get => Reference.MinItems ?? Target?.MinItems; set => Reference.MinItems = value; } + public int? MinItems + { + get => Target?.MinItems is { } tMin && Reference.MinItems is { } rMin + ? Math.Max(tMin, rMin) + : Target?.MinItems ?? Reference.MinItems; + set => Reference.MinItems = value; + } /// public bool? UniqueItems { get => Reference.UniqueItems ?? Target?.UniqueItems; set => Reference.UniqueItems = value; } /// @@ -133,11 +157,27 @@ public bool WriteOnly /// public IDictionary? PatternProperties { get => Reference.PatternProperties ?? Target?.PatternProperties; set => Reference.PatternProperties = value; } /// - public int? MaxProperties { get => Reference.MaxProperties ?? Target?.MaxProperties; set => Reference.MaxProperties = value; } + public int? MaxProperties + { + get => Target?.MaxProperties is { } tMax && Reference.MaxProperties is { } rMax + ? Math.Min(tMax, rMax) + : Target?.MaxProperties ?? Reference.MaxProperties; + set => Reference.MaxProperties = value; + } /// - public int? MinProperties { get => Reference.MinProperties ?? Target?.MinProperties; set => Reference.MinProperties = value; } + public int? MinProperties + { + get => Target?.MinProperties is { } tMin && Reference.MinProperties is { } rMin + ? Math.Max(tMin, rMin) + : Target?.MinProperties ?? Reference.MinProperties; + set => Reference.MinProperties = value; + } /// - public bool AdditionalPropertiesAllowed { get => Reference.AdditionalPropertiesAllowed ?? Target?.AdditionalPropertiesAllowed ?? true; set => Reference.AdditionalPropertiesAllowed = value; } + public bool AdditionalPropertiesAllowed + { + get => Reference.AdditionalPropertiesAllowed ?? Target?.AdditionalPropertiesAllowed ?? true; + set => Reference.AdditionalPropertiesAllowed = value; + } /// public IOpenApiSchema? AdditionalProperties { get => Reference.AdditionalProperties ?? Target?.AdditionalProperties; set => Reference.AdditionalProperties = value; } /// @@ -153,7 +193,11 @@ public IList? Examples /// public IList? Enum { get => Reference.Enum ?? Target?.Enum; set => Reference.Enum = value; } /// - public bool UnevaluatedProperties { get => Reference.UnevaluatedProperties ?? Target?.UnevaluatedProperties ?? true; set => Reference.UnevaluatedProperties = value; } + public bool UnevaluatedProperties + { + get => Reference.UnevaluatedProperties ?? Target?.UnevaluatedProperties ?? true; + set => Reference.UnevaluatedProperties = value; + } /// public IOpenApiSchema? UnevaluatedPropertiesSchema { get => Reference.UnevaluatedPropertiesSchema ?? (Target as IOpenApiSchemaMissingProperties)?.UnevaluatedPropertiesSchema; set => Reference.UnevaluatedPropertiesSchema = value; } /// diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs index 8f6cb3107..d988c9121 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs @@ -165,7 +165,7 @@ public void SchemaReferenceExposesMissingPropertiesFromTarget() } [Fact] - public void SchemaReferenceWithKeywordSiblingsShouldOverrideTargetValues() + public void SchemaReferenceWithKeywordSiblingsDoesNotOverrideTargetValues() { var workingDocument = new OpenApiDocument { @@ -206,16 +206,33 @@ public void SchemaReferenceWithKeywordSiblingsShouldOverrideTargetValues() If = new OpenApiSchema { Required = new HashSet { "reference" } } }; + // Non-lossless keywords remain reference-first convenience accessors. Assert.Equal(JsonSchemaType.String, schemaReference.Type); Assert.Equal("reference-format", schemaReference.Format); - Assert.Equal(10, schemaReference.MaxLength); + Assert.Equal(JsonSchemaType.Integer, schemaReference.Properties?["reference"].Type); + Assert.NotNull(schemaReference.If?.Required); + Assert.Contains("reference", schemaReference.If.Required); + + // Numeric bounds: stricter value wins (both constraints apply). + Assert.Equal(10, schemaReference.MaxLength); // Math.Min(20, 10) + + // Required is a mutable sibling collection, not an effective merged set. Assert.NotNull(schemaReference.Required); Assert.Contains("reference", schemaReference.Required); - Assert.Equal(JsonSchemaType.Integer, schemaReference.Properties?["reference"].Type); + Assert.DoesNotContain("target", schemaReference.Required); + + // AdditionalPropertiesAllowed is reference-first (location-sensitive, not collapsible). Assert.False(schemaReference.AdditionalPropertiesAllowed); + + // ContentEncoding is an annotation — sibling value takes precedence. Assert.Equal("base64", schemaReference.ContentEncoding); - Assert.NotNull(schemaReference.If?.Required); - Assert.Contains("reference", schemaReference.If.Required); + + // Authored sibling values are still stored on the Reference. + Assert.Equal(JsonSchemaType.String, schemaReference.Reference.SchemaType); + Assert.Equal("reference-format", schemaReference.Reference.Format); + Assert.Equal(10, schemaReference.Reference.MaxLength); + Assert.Contains("reference", schemaReference.Reference.Required ?? new HashSet()); + Assert.False(schemaReference.Reference.AdditionalPropertiesAllowed ?? true); } [Fact] @@ -276,16 +293,37 @@ public void ParseSchemaReferenceWithKeywordSiblingsWorks() Assert.Empty(readResult.Diagnostic.Errors); var schemaReference = Assert.IsType(readResult.Document?.Paths["/test"].Operations[HttpMethod.Get] .Responses["200"].Content["application/json"].Schema); + + // Type/Format: non-lossless convenience getters remain reference-first. Assert.Equal(JsonSchemaType.String, schemaReference.Type); Assert.Equal("uuid", schemaReference.Format); - Assert.Equal(36, schemaReference.MaxLength); + + // MaxLength: stricter wins — Math.Min(10, 36) = 10. + Assert.Equal(10, schemaReference.MaxLength); + + // Required: target has none, sibling has ["id"] → union = ["id"]. Assert.NotNull(schemaReference.Required); Assert.Contains("id", schemaReference.Required); + + // Properties: target has none → sibling fallback. Assert.Equal(JsonSchemaType.String, schemaReference.Properties?["id"].Type); + + // AdditionalPropertiesAllowed: target true, sibling false → false. Assert.False(schemaReference.AdditionalPropertiesAllowed); - Assert.Equal("base64", schemaReference.ContentEncoding); + + // If: target has none → sibling fallback. Assert.NotNull(schemaReference.If?.Required); Assert.Contains("id", schemaReference.If.Required); + + // ContentEncoding is an annotation — sibling value takes precedence. + Assert.Equal("base64", schemaReference.ContentEncoding); + + // Authored sibling structural values are preserved on the Reference. + Assert.Equal(JsonSchemaType.String, schemaReference.Reference.SchemaType); + Assert.Equal("uuid", schemaReference.Reference.Format); + Assert.Equal(36, schemaReference.Reference.MaxLength); + Assert.Contains("id", schemaReference.Reference.Required ?? new HashSet()); + Assert.False(schemaReference.Reference.AdditionalPropertiesAllowed ?? true); } [Theory] @@ -612,5 +650,429 @@ public async Task SchemaReferenceExtensionsNotWrittenInV2() // Assert: In v2, ONLY $ref should appear - no description, no extensions Assert.Equal(@"{""$ref"":""#/definitions/Pet""}", output); } + + [Fact] + public void StructuralSiblingDoesNotRelaxTargetAssertion() + { + // Per JSON Schema 2020-12 (and handrews' comment on #2919): + // assertion keywords alongside $ref MUST be evaluated independently. + // A sibling minProperties: 2 does NOT relax a target minProperties: 5. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + MinProperties = 5 + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + MinProperties = 2 + }; + + // Math.Max(5, 2) = 5 — the stricter constraint applies. + Assert.Equal(5, schemaRef.MinProperties); + // Sibling value is preserved on the Reference. + Assert.Equal(2, schemaRef.Reference.MinProperties); + } + + [Fact] + public void PropertiesSiblingRemainsVisibleOnConvenienceGetter() + { + // Per handrews: applicators like "properties" MUST be evaluated independently. + // A sibling properties keyword does not replace the target's properties. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }; + + // Properties cannot be losslessly collapsed when both sides define the same property. + // Keep the convenience getter reference-first so the sibling applicator remains visible. + Assert.Contains("id", schemaRef.Properties?.Keys ?? new Dictionary().Keys); + Assert.DoesNotContain("name", schemaRef.Properties?.Keys ?? new Dictionary().Keys); + // Target value remains available separately. + Assert.Contains("name", schemaRef.Target?.Properties?.Keys ?? new Dictionary().Keys); + // Sibling value is preserved on the Reference. + Assert.Contains("id", schemaRef.Reference.Properties?.Keys ?? new Dictionary().Keys); + } + + [Fact] + public void ContainsCountKeywordsStayAlignedWithSiblingContainsApplicator() + { + // contains/minContains/maxContains communicate as a group. Since contains + // cannot be losslessly collapsed, the related count keywords remain + // reference-first too. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 5, + MaxContains = 10 + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + Contains = new OpenApiSchema { Type = JsonSchemaType.Number }, + MinContains = 2, + MaxContains = 3 + }; + + Assert.Equal(JsonSchemaType.Number, schemaRef.Contains?.Type); + Assert.Equal(2U, schemaRef.MinContains); + Assert.Equal(3U, schemaRef.MaxContains); + Assert.Equal(JsonSchemaType.String, schemaRef.Target is IOpenApiSchemaMissingProperties target ? target.Contains?.Type : null); + Assert.Equal(5U, schemaRef.Target is IOpenApiSchemaMissingProperties targetMin ? targetMin.MinContains : null); + Assert.Equal(10U, schemaRef.Target is IOpenApiSchemaMissingProperties targetMax ? targetMax.MaxContains : null); + } + + [Fact] + public void ReadOnlyIsTrueIfEitherSourceIsTrue() + { + // Per handrews on #2919: if anything marks an instance location + // readOnly: true then it SHOULD be considered read-only even if + // some other subschema marks it readOnly: false. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["TargetRO"] = new OpenApiSchema { ReadOnly = true }, + ["TargetNotRO"] = new OpenApiSchema { ReadOnly = false }, + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + // Target readOnly=true, sibling not set → true + var ref1 = new OpenApiSchemaReference("TargetRO", workingDocument); + Assert.True(ref1.ReadOnly); + + // Target readOnly=true, sibling readOnly=false → still true + var ref2 = new OpenApiSchemaReference("TargetRO", workingDocument) { ReadOnly = false }; + Assert.True(ref2.ReadOnly); + + // Target readOnly=false, sibling readOnly=true → true + var ref3 = new OpenApiSchemaReference("TargetNotRO", workingDocument) { ReadOnly = true }; + Assert.True(ref3.ReadOnly); + + // Target readOnly=false, sibling not set → false + var ref4 = new OpenApiSchemaReference("TargetNotRO", workingDocument); + Assert.False(ref4.ReadOnly); + } + + [Fact] + public void WriteOnlyIsTrueIfEitherSourceIsTrue() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["TargetWO"] = new OpenApiSchema { WriteOnly = true }, + ["TargetNotWO"] = new OpenApiSchema { WriteOnly = false }, + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var ref1 = new OpenApiSchemaReference("TargetWO", workingDocument); + Assert.True(ref1.WriteOnly); + + var ref2 = new OpenApiSchemaReference("TargetWO", workingDocument) { WriteOnly = false }; + Assert.True(ref2.WriteOnly); + + var ref3 = new OpenApiSchemaReference("TargetNotWO", workingDocument) { WriteOnly = true }; + Assert.True(ref3.WriteOnly); + + var ref4 = new OpenApiSchemaReference("TargetNotWO", workingDocument); + Assert.False(ref4.WriteOnly); + } + + [Fact] + public async Task ParseSchemaReferenceSiblingSerializationRoundTrip() + { + // Verifies that $ref siblings survive parse → serialize round-trip + // even though structural getters now resolve from Target. + var jsonContent = @"{ + ""openapi"": ""3.1.0"", + ""info"": { + ""title"": ""Test API"", + ""version"": ""1.0.0"" + }, + ""paths"": { + ""/test"": { + ""get"": { + ""responses"": { + ""200"": { + ""description"": ""OK"", + ""content"": { + ""application/json"": { + ""schema"": { + ""$ref"": ""#/components/schemas/Target"", + ""minProperties"": 2, + ""description"": ""sibling description"" + } + } + } + } + } + } + } + }, + ""components"": { + ""schemas"": { + ""Target"": { + ""type"": ""object"", + ""minProperties"": 5 + } + } + } +}"; + + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + Assert.Empty(readResult.Diagnostic.Errors); + + var doc = readResult.Document!; + var schemaRef = Assert.IsType(doc.Paths["/test"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema); + + // Structural getter resolves from Target. + Assert.Equal(5, schemaRef.MinProperties); + // Annotation getter resolves from sibling. + Assert.Equal("sibling description", schemaRef.Description); + // Sibling structural value preserved on Reference. + Assert.Equal(2, schemaRef.Reference.MinProperties); + + // Serialize back and verify siblings round-trip on the $ref wire format. + using var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true }); + schemaRef.SerializeAsV31(writer); + await writer.FlushAsync(); + var output = outputStringWriter.ToString(); + + Assert.Contains("\"$ref\":\"#/components/schemas/Target\"", output); + Assert.Contains("\"minProperties\":2", output); + Assert.Contains("\"description\":\"sibling description\"", output); + } + + [Fact] + public void StricterSiblingEnforcesTighterBound() + { + // A sibling minProperties: 7 is stricter than target minProperties: 5. + // Both apply, so the effective constraint is Math.Max(5, 7) = 7. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + MinProperties = 5, + MaxProperties = 20 + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + MinProperties = 7, + MaxProperties = 10 + }; + + Assert.Equal(7, schemaRef.MinProperties); // Math.Max(5, 7) + Assert.Equal(10, schemaRef.MaxProperties); // Math.Min(20, 10) + } + + [Fact] + public void RawNumericBoundSiblingsRemainVisible() + { + // maximum/minimum are stored as raw strings to preserve JSON number precision. + // Do not parse and compare them here; keep sibling-authored values visible. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + Maximum = "100", + Minimum = "1" + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + Maximum = "10", + Minimum = "5" + }; + + Assert.Equal("10", schemaRef.Maximum); + Assert.Equal("5", schemaRef.Minimum); + Assert.Equal("100", schemaRef.Target?.Maximum); + Assert.Equal("1", schemaRef.Target?.Minimum); + + var relaxedSibling = new OpenApiSchemaReference("Target", workingDocument) + { + Maximum = "200", + Minimum = "0" + }; + + Assert.Equal("200", relaxedSibling.Maximum); + Assert.Equal("0", relaxedSibling.Minimum); + } + + [Fact] + public void RequiredSiblingCollectionRemainsMutableAndStable() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "name" } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + Required = new HashSet { "id" } + }; + + Assert.NotNull(schemaRef.Required); + Assert.Contains("id", schemaRef.Required); + Assert.DoesNotContain("name", schemaRef.Required); + + schemaRef.Required.Add("email"); + + Assert.Contains("email", schemaRef.Required); + Assert.Contains("email", schemaRef.Reference.Required ?? new HashSet()); + Assert.DoesNotContain("email", schemaRef.Target?.Required ?? new HashSet()); + } + + [Fact] + public void AdditionalPropertiesAllowedIsReferenceFirst() + { + // additionalProperties is location-sensitive and cannot be safely collapsed + // into a single effective boolean. Keep reference-first convenience behavior. + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["TargetAPA"] = new OpenApiSchema { AdditionalPropertiesAllowed = true }, + ["TargetNoAPA"] = new OpenApiSchema { AdditionalPropertiesAllowed = false }, + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + // Sibling false overrides target true (reference-first). + var ref1 = new OpenApiSchemaReference("TargetAPA", workingDocument) { AdditionalPropertiesAllowed = false }; + Assert.False(ref1.AdditionalPropertiesAllowed); + + // No sibling → target fallback. + var ref2 = new OpenApiSchemaReference("TargetNoAPA", workingDocument); + Assert.False(ref2.AdditionalPropertiesAllowed); + + // No sibling → target fallback. + var ref3 = new OpenApiSchemaReference("TargetAPA", workingDocument); + Assert.True(ref3.AdditionalPropertiesAllowed); + } + + [Fact] + public void UnresolvedReferenceSiblingValuesAreReadable() + { + // When there is no host document, Target is null. + // Structural getters must fall back to the authored sibling values. + var schemaRef = new OpenApiSchemaReference("Pet", null) + { + Type = JsonSchemaType.String, + Format = "uuid", + MaxLength = 36, + Required = new HashSet { "id" } + }; + + Assert.Equal(JsonSchemaType.String, schemaRef.Type); + Assert.Equal("uuid", schemaRef.Format); + Assert.Equal(36, schemaRef.MaxLength); + Assert.Contains("id", schemaRef.Required ?? new HashSet()); + } + + [Fact] + public void AllOfSiblingCollectionRemainsMutableAndStable() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + workingDocument.Components.Schemas = new Dictionary + { + ["Target"] = new OpenApiSchema + { + AllOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.Object } + } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaRef = new OpenApiSchemaReference("Target", workingDocument) + { + AllOf = new List + { + new OpenApiSchema { Type = JsonSchemaType.String } + } + }; + + Assert.NotNull(schemaRef.AllOf); + Assert.Single(schemaRef.AllOf); + Assert.Equal(JsonSchemaType.String, schemaRef.AllOf[0].Type); + + schemaRef.AllOf.Add(new OpenApiSchema { Type = JsonSchemaType.Boolean }); + + Assert.Equal(2, schemaRef.AllOf.Count); + Assert.Equal(2, schemaRef.Reference.AllOf?.Count); + Assert.Single(schemaRef.Target?.AllOf ?? []); + } } }