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);
}