Skip to content

Validate OpenAPI schema references #2459

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/Microsoft.OpenApi/Properties/SRResource.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Microsoft.OpenApi/Properties/SRResource.resx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@
<data name="ParseServerUrlValueNotValid" xml:space="preserve">
<value>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>
</data>
<data name="Validation_SchemaReferenceDoesNotExist" xml:space="preserve">
<value>The schema reference '{0}' does not point to an existing schema.</value>
</data>
<data name="ArgumentNull" xml:space="preserve">
<value>The argument '{0}' is null.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,81 @@ 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();
});

/// <summary>
/// All references in the OpenAPI document must be valid.
/// </summary>
public static ValidationRule<OpenApiDocument> OpenApiDocumentReferencesAreValid =>
new(nameof(OpenApiDocumentReferencesAreValid),
static (context, item) =>
{
const string RuleName = nameof(OpenApiDocumentReferencesAreValid);

var visitor = new OpenApiSchemaReferenceVisitor(RuleName, context);
var walker = new OpenApiWalker(visitor);

walker.Walk(item);
});

private sealed class OpenApiSchemaReferenceVisitor(
string ruleName,
IValidationContext context) : OpenApiVisitorBase
{
public override void Visit(IOpenApiReferenceHolder referenceHolder)
{
if (referenceHolder is OpenApiSchemaReference reference)
{
ValidateSchemaReference(reference);
}
}

public override void Visit(IOpenApiSchema schema)
{
if (schema is OpenApiSchemaReference reference)
{
ValidateSchemaReference(reference);
}
}

private void ValidateSchemaReference(OpenApiSchemaReference reference)
{
if (!reference.Reference.IsLocal)
{
return;
}

try
{
if (reference.RecursiveTarget is null)
{
// The reference was not followed to a valid schema somewhere in the document
context.Enter(GetSegment());
context.CreateWarning(ruleName, string.Format(SRResource.Validation_SchemaReferenceDoesNotExist, reference.Reference.ReferenceV3));
context.Exit();
}
}
catch (InvalidOperationException ex)
{
context.Enter(GetSegment());
context.CreateWarning(ruleName, ex.Message);
context.Exit();
}

string GetSegment()
{
// Trim off the leading "#/" as the context is already at the root of the document
return
#if NET8_0_OR_GREATER
$"{PathString[2..]}/$ref";
#else
PathString.Substring(2) + "/$ref";
#endif
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ namespace Microsoft.OpenApi
public static class OpenApiDocumentRules
{
public static Microsoft.OpenApi.ValidationRule<Microsoft.OpenApi.OpenApiDocument> OpenApiDocumentFieldIsMissing { get; }
public static Microsoft.OpenApi.ValidationRule<Microsoft.OpenApi.OpenApiDocument> OpenApiDocumentReferencesAreValid { get; }
}
public static class OpenApiElementExtensions
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, IOpenApiSchema>()
{
["name"] = new OpenApiSchema { Type = JsonSchemaType.String },
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" }
}
});

document.Paths.Add("/people", new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation>()
{
[HttpMethod.Get] = new OpenApiOperation
{
Responses = new()
{
["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>()
{
["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<string, IOpenApiSchema>()
{
["name"] = new OpenApiSchema { Type = JsonSchemaType.String },
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" }
}
});

document.Paths.Add("/pets", new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation>()
{
[HttpMethod.Get] = new OpenApiOperation
{
Responses = new()
{
["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>()
{
["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<string, IOpenApiSchema>()
{
["self"] = new OpenApiSchemaReference("#/components/schemas/Cycle/properties/self", document)
}
});

document.Paths.Add("/cycle", new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation>()
{
[HttpMethod.Get] = new OpenApiOperation
{
Responses = new()
{
["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>()
{
["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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading