diff --git a/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj b/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj index 6dbcbf20f..dad533668 100644 --- a/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj +++ b/src/Microsoft.OpenApi/Microsoft.OpenApi.csproj @@ -41,6 +41,7 @@ ResXFileCodeGenerator SRResource.Designer.cs + Microsoft.OpenApi diff --git a/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs b/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs index 4e4717e0d..bd42672a5 100644 --- a/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs +++ b/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -69,25 +70,23 @@ internal static string ActiveScopeNeededForPropertyNameWriting { } /// - /// Looks up a localized string similar to The argument '{0}' is null, empty or consists only of white-space.. + /// Looks up a localized string similar to The argument '{0}' is null.. /// - internal static string ArgumentNullOrWhiteSpace { + internal static string ArgumentNull { get { - return ResourceManager.GetString("ArgumentNullOrWhiteSpace", resourceCulture); + return ResourceManager.GetString("ArgumentNull", resourceCulture); } } - + /// - /// Looks up a localized string similar to The argument '{0}' is null.. + /// Looks up a localized string similar to The argument '{0}' is null, empty or consists only of white-space.. /// - internal static string ArgumentNull - { - get - { - return ResourceManager.GetString("ArgumentNull", resourceCulture); + internal static string ArgumentNullOrWhiteSpace { + get { + return ResourceManager.GetString("ArgumentNullOrWhiteSpace", resourceCulture); } } - + /// /// Looks up a localized string similar to http://localhost/. /// @@ -385,6 +384,15 @@ internal static string Validation_RuleAddTwice { } } + /// + /// Looks up a localized string similar to The schema reference '{0}' does not point to an existing schema.. + /// + internal static string Validation_SchemaReferenceDoesNotExist { + get { + return ResourceManager.GetString("Validation_SchemaReferenceDoesNotExist", resourceCulture); + } + } + /// /// Looks up a localized string similar to Schema {0} must contain property specified in the discriminator {1} in the required field list.. /// @@ -412,27 +420,5 @@ internal static string WorkspaceRequredForExternalReferenceResolution { return ResourceManager.GetString("WorkspaceRequredForExternalReferenceResolution", resourceCulture); } } - - /// - /// Looks up a localized string similar to The HostDocument is null.. - /// - internal static string HostDocumentIsNull - { - get - { - return ResourceManager.GetString("HostDocumentIsNull", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The identifier in the referenced element is null or empty .. - /// - internal static string ReferenceIdIsNullOrEmpty - { - get - { - return ResourceManager.GetString("ReferenceIdIsNullOrEmpty", resourceCulture); - } - } } } diff --git a/src/Microsoft.OpenApi/Properties/SRResource.resx b/src/Microsoft.OpenApi/Properties/SRResource.resx index 0effa1d44..b758ff89b 100644 --- a/src/Microsoft.OpenApi/Properties/SRResource.resx +++ b/src/Microsoft.OpenApi/Properties/SRResource.resx @@ -226,9 +226,15 @@ OpenAPI document must be added to an OpenApiWorkspace to be able to resolve external references. - Invalid server variable '{0}'. A value was not provided and no default value was provided. + Invalid server variable '{0}'. A value was not provided and no default value was provided. - Value '{0}' is not a valid value for variable '{1}'. If an enum is provided, it should not be empty and the value provided should exist in the enum + Value '{0}' is not a valid value for variable '{1}'. If an enum is provided, it should not be empty and the value provided should exist in the enum - + + The schema reference '{0}' does not point to an existing schema. + + + The argument '{0}' is null. + + \ No newline at end of file diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs index a0bff927e..e50c27745 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. using System; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Reader; namespace Microsoft.OpenApi { @@ -23,9 +25,125 @@ public static class OpenApiDocumentRules if (item.Info == null) { context.CreateError(nameof(OpenApiDocumentFieldIsMissing), - String.Format(SRResource.Validation_FieldIsRequired, "info", "document")); + string.Format(SRResource.Validation_FieldIsRequired, "info", "document")); } context.Exit(); }); + + /// + /// All references in the OpenAPI document must be valid. + /// + public static ValidationRule OpenApiDocumentReferencesAreValid => + new(nameof(OpenApiDocumentReferencesAreValid), + static (context, item) => + { + const string RuleName = nameof(OpenApiDocumentReferencesAreValid); + + JsonNode document; + + using (var textWriter = new System.IO.StringWriter()) + { + var writer = new OpenApiJsonWriter(textWriter); + + item.SerializeAsV31(writer); + + var json = textWriter.ToString(); + + document = JsonNode.Parse(json)!; + } + + var visitor = new OpenApiSchemaReferenceVisitor(RuleName, context, document); + var walker = new OpenApiWalker(visitor); + + walker.Walk(item); + }); + + private sealed class OpenApiSchemaReferenceVisitor( + string ruleName, + IValidationContext context, + JsonNode document) : OpenApiVisitorBase + { + public override void Visit(IOpenApiReferenceHolder referenceHolder) + { + if (referenceHolder is OpenApiSchemaReference { Reference.IsLocal: true } reference) + { + ValidateSchemaReference(reference); + } + } + + public override void Visit(IOpenApiSchema schema) + { + if (schema is OpenApiSchemaReference { Reference.IsLocal: true } reference) + { + ValidateSchemaReference(reference); + } + } + + private void ValidateSchemaReference(OpenApiSchemaReference reference) + { + // Trim off the leading "#/" as the context is already at the root of the document + var segment = +#if NET8_0_OR_GREATER + $"{PathString[2..]}/$ref"; +#else + PathString.Substring(2) + "/$ref"; +#endif + + try + { + if (reference.RecursiveTarget is not null) + { + // The reference was followed to a valid schema somewhere in the document + return; + } + } + catch (InvalidOperationException ex) + { + context.Enter(segment); + context.CreateWarning(ruleName, ex.Message); + context.Exit(); + + return; + } + + var id = reference.Reference.ReferenceV3; + + if (id is { Length: > 0 } && !IsValidSchemaReference(id, document)) + { + var isValid = false; + + // Sometimes ReferenceV3 is not a JSON valid JSON pointer, but the $ref + // associated with it still points to a valid location in the document. + // In these cases, we need to find it manually to verify that fact before + // generating a warning that the schema reference is indeed invalid. + // TODO Why is this, and can it be avoided? + var parent = Find(PathString, document); + + if (parent?["$ref"] is { } @ref && + @ref.GetValueKind() is System.Text.Json.JsonValueKind.String && + @ref.GetValue() is { Length: > 0 } refId) + { + id = refId; + isValid = IsValidSchemaReference(id, document); + } + + if (!isValid) + { + context.Enter(segment); + context.CreateWarning(ruleName, string.Format(SRResource.Validation_SchemaReferenceDoesNotExist, id)); + context.Exit(); + } + } + + static bool IsValidSchemaReference(string id, JsonNode baseNode) + => Find(id, baseNode) is not null; + + static JsonNode? Find(string id, JsonNode baseNode) + { + var pointer = new JsonPointer(id.Replace("#/", "/")); + return pointer.Find(baseNode); + } + } + } } } diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index 891d0d4f4..f8d83d291 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -627,6 +627,7 @@ namespace Microsoft.OpenApi public static class OpenApiDocumentRules { public static Microsoft.OpenApi.ValidationRule OpenApiDocumentFieldIsMissing { get; } + public static Microsoft.OpenApi.ValidationRule OpenApiDocumentReferencesAreValid { get; } } public static class OpenApiElementExtensions { diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiDocumentValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiDocumentValidationTests.cs new file mode 100644 index 000000000..467d5f3a7 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Validations/OpenApiDocumentValidationTests.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using Xunit; + +namespace Microsoft.OpenApi.Validations.Tests; + +public static class OpenApiDocumentValidationTests +{ + [Fact] + public static void ValidateSchemaReferencesAreValid() + { + // Arrange + var document = new OpenApiDocument + { + Components = new OpenApiComponents(), + Info = new OpenApiInfo + { + Title = "People Document", + Version = "1.0.0" + }, + Paths = [], + Workspace = new() + }; + + document.AddComponent("Person", new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary() + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" } + } + }); + + document.Paths.Add("/people", new OpenApiPathItem + { + Operations = new Dictionary() + { + [HttpMethod.Get] = new OpenApiOperation + { + Responses = new() + { + ["200"] = new OpenApiResponse + { + Description = "OK", + Content = new Dictionary() + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("Person", document), + } + } + } + } + } + } + }); + + // Act + var errors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + var result = !errors.Any(); + + // Assert + Assert.True(result); + Assert.NotNull(errors); + Assert.Empty(errors); + } + + [Fact] + public static void ValidateSchemaReferencesAreInvalid() + { + // Arrange + var document = new OpenApiDocument + { + Components = new OpenApiComponents(), + Info = new OpenApiInfo + { + Title = "Pets Document", + Version = "1.0.0" + }, + Paths = [], + Workspace = new() + }; + + document.AddComponent("Person", new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary() + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" } + } + }); + + document.Paths.Add("/pets", new OpenApiPathItem + { + Operations = new Dictionary() + { + [HttpMethod.Get] = new OpenApiOperation + { + Responses = new() + { + ["200"] = new OpenApiResponse + { + Description = "OK", + Content = new Dictionary() + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("Pet", document), + } + } + } + } + } + } + }); + + // Act + var errors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + var result = !errors.Any(); + + // Assert + Assert.False(result); + Assert.NotNull(errors); + var error = Assert.Single(errors); + Assert.Equal("The schema reference '#/components/schemas/Pet' does not point to an existing schema.", error.Message); + Assert.Equal("#/paths/~1pets/get/responses/200/content/application~1json/schema/$ref", error.Pointer); + } + + [Fact] + public static void ValidateCircularSchemaReferencesAreDetected() + { + // Arrange + var document = new OpenApiDocument + { + Components = new OpenApiComponents(), + Info = new OpenApiInfo + { + Title = "Infinite Document", + Version = "1.0.0" + }, + Paths = [], + Workspace = new() + }; + + document.AddComponent("Cycle", new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary() + { + ["self"] = new OpenApiSchemaReference("#/components/schemas/Cycle/properties/self", document) + } + }); + + document.Paths.Add("/cycle", new OpenApiPathItem + { + Operations = new Dictionary() + { + [HttpMethod.Get] = new OpenApiOperation + { + Responses = new() + { + ["200"] = new OpenApiResponse + { + Description = "OK", + Content = new Dictionary() + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference("Cycle", document) + } + } + } + } + } + } + }); + + // Act + var errors = document.Validate(ValidationRuleSet.GetDefaultRuleSet()); + var result = !errors.Any(); + + // Assert + Assert.False(result); + Assert.NotNull(errors); + var error = Assert.Single(errors); + Assert.Equal("Circular reference detected while resolving schema: #/components/schemas/Cycle/properties/self", error.Message); + Assert.Equal("#/components/schemas/Cycle/properties/self/$ref", error.Pointer); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Validations/ValidationRuleSetTests.cs b/test/Microsoft.OpenApi.Tests/Validations/ValidationRuleSetTests.cs index 80ac348cb..19757a54e 100644 --- a/test/Microsoft.OpenApi.Tests/Validations/ValidationRuleSetTests.cs +++ b/test/Microsoft.OpenApi.Tests/Validations/ValidationRuleSetTests.cs @@ -53,8 +53,8 @@ public void RuleSetConstructorsReturnsTheCorrectRules() Assert.Empty(ruleSet_4.Rules); // Update the number if you add new default rule(s). - Assert.Equal(19, ruleSet_1.Rules.Count); - Assert.Equal(19, ruleSet_2.Rules.Count); + Assert.Equal(20, ruleSet_1.Rules.Count); + Assert.Equal(20, ruleSet_2.Rules.Count); Assert.Equal(3, ruleSet_3.Rules.Count); }