Skip to content

Commit

Permalink
Add assertion to validate against JSON schema
Browse files Browse the repository at this point in the history
  • Loading branch information
Namoshek committed Jan 25, 2025
1 parent 417e187 commit df95d25
Show file tree
Hide file tree
Showing 3 changed files with 260 additions and 16 deletions.
1 change: 1 addition & 0 deletions src/Shouldly.Json/Shouldly.Json.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<ItemGroup>
<PackageReference Include="Json.More.Net" Version="2.1.0" />
<PackageReference Include="JsonPointer.Net" Version="5.3.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
</ItemGroup>

Expand Down
100 changes: 84 additions & 16 deletions src/Shouldly.Json/ShouldlyJsonExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Pointer;
using Json.More;
using Humanizer;
using Json.Pointer;
using Json.Schema;

public static class ShouldlyJsonExtensions
{
Expand All @@ -23,12 +24,12 @@ public static void ShouldBeSemanticallySameJson(this string? actual, string? exp
{
var errorMessage = customMessage ?? "JSON strings should be semantically the same";

if (actual == null && expected == null)
if (actual is null && expected is null)
{
return;
}

if (actual == null || expected == null)
if (actual is null || expected is null)
{
throw new ShouldAssertException(new ExpectedActualShouldlyMessage(expected, actual, errorMessage).ToString());
}
Expand Down Expand Up @@ -62,12 +63,12 @@ public static void ShouldBeJsonSubtreeOf(this string? actual, string? expected,
{
var errorMessage = customMessage ?? "JSON should be a subtree of expected JSON";

if (actual == null && expected == null)
if (actual is null && expected is null)
{
return;
}

if (actual == null || expected == null)
if (actual is null || expected is null)
{
throw new ShouldAssertException(new ExpectedActualShouldlyMessage(expected, actual, errorMessage).ToString());
}
Expand Down Expand Up @@ -102,7 +103,7 @@ public static void ShouldBeValidJson(this string? actual, string? customMessage
{
var errorMessage = customMessage ?? "String should be valid JSON";

if (actual == null)
if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, errorMessage).ToString());
}
Expand Down Expand Up @@ -138,7 +139,7 @@ public static void ShouldHaveJsonValueAt<T>(
{
var errorMessage = customMessage ?? $"JSON should have value '{expectedValue}' at pointer '{jsonPointer}'";

if (actual == null)
if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, errorMessage).ToString());
}
Expand All @@ -147,12 +148,12 @@ public static void ShouldHaveJsonValueAt<T>(
{
var actualValue = JsonHelper.GetValueAtPointer<T>(actual, jsonPointer, allowNull: true);

if (actualValue == null && expectedValue == null)
if (actualValue is null && expectedValue is null)
{
return;
}

if (actualValue == null)
if (actualValue is null)
{
throw new ShouldAssertException(new ExpectedActualShouldlyMessage(expectedValue, actualValue, errorMessage).ToString());
}
Expand Down Expand Up @@ -416,15 +417,15 @@ public static void ShouldHaveJsonProperty(this string? actual, string jsonPointe
{
var errorMessage = customMessage ?? $"JSON should have a property at pointer '{jsonPointer}'";

if (actual == null)
if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string is null").ToString());
}

try
{
var jsonNode = JsonNode.Parse(actual);
if (jsonNode == null)
if (jsonNode is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string parsed to null").ToString());
}
Expand Down Expand Up @@ -455,7 +456,7 @@ public static void ShouldBeJsonObject(this string? actual, string? customMessage
{
var errorMessage = customMessage ?? "JSON string should have an object as root element";

if (actual == null)
if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string is null").ToString());
}
Expand Down Expand Up @@ -484,7 +485,7 @@ public static void ShouldBeJsonArray(this string? actual, string? customMessage
{
var errorMessage = customMessage ?? "JSON string should have an array as root element";

if (actual == null)
if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string is null").ToString());
}
Expand Down Expand Up @@ -515,15 +516,15 @@ public static void ShouldHaveJsonArrayCount(this string? actual, int expectedCou
{
var errorMessage = customMessage ?? $"JSON array at pointer '{jsonPointer}' should have {expectedCount} elements";

if (actual == null)
if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string is null").ToString());
}

try
{
var jsonNode = JsonNode.Parse(actual);
if (jsonNode == null)
if (jsonNode is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string parsed to null").ToString());
}
Expand Down Expand Up @@ -557,6 +558,73 @@ public static void ShouldHaveJsonArrayCount(this string? actual, int expectedCou
}
}

/// <summary>
/// Asserts that the JSON string is valid according to the provided JSON schema.
/// </summary>
/// <param name="actual">The JSON string to validate.</param>
/// <param name="schema">The JSON schema to validate against.</param>
/// <param name="customMessage">An optional custom message to include in the exception if the assertion fails.</param>
/// <exception cref="ShouldAssertException">Thrown if the JSON is invalid or does not conform to the schema.</exception>
public static void ShouldMatchJsonSchema(this string? actual, string schema, string? customMessage = null)
{
var errorMessage = customMessage ?? "JSON should match the provided schema";

if (actual is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string is null").ToString());
}

JsonSchema? jsonSchema = null;
try
{
jsonSchema = JsonSchema.FromText(schema);
if (jsonSchema is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "Invalid JSON Schema").ToString());
}
}
catch (JsonException ex)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, $"Invalid JSON Schema: {ex.Message}").ToString());
}

try
{
var jsonNode = JsonNode.Parse(actual);

if (jsonNode is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON string parsed to null").ToString());
}

var evaluationResults = jsonSchema.Evaluate(jsonNode, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true,
});

if (evaluationResults is null)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, "JSON Schema evaluation failed").ToString());
}

if (!evaluationResults.IsValid)
{
var errors = evaluationResults.Details.SelectMany(d => d.Errors?.Select(kvp => $"{kvp.Key}: {kvp.Value}") ?? []);

throw new ShouldAssertException(new ActualShouldlyMessage(actual, $"{errorMessage}:\n{string.Join("\n", errors)}").ToString());
}
}
catch (JsonException ex)
{
throw new ShouldAssertException(new ActualShouldlyMessage(actual, $"Invalid JSON: {ex.Message}").ToString());
}
catch (JsonSchemaException ex)
{
throw new ShouldAssertException(new ActualShouldlyMessage(schema, $"Invalid JSON Schema: {ex.Message}").ToString());
}
}

/// <summary>
/// Gets and validates a JSON value at the specified pointer path.
/// </summary>
Expand Down
175 changes: 175 additions & 0 deletions tests/Shouldly.Json.Tests/ShouldMatchJsonSchemaTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
namespace Shouldly;

public class ShouldMatchJsonSchemaTests
{
[Fact]
public void ShouldMatchJsonSchema_WithValidJson_ShouldNotThrow()
{
var schema = @"{
""type"": ""object"",
""properties"": {
""name"": { ""type"": ""string"" },
""age"": { ""type"": ""integer"", ""minimum"": 0 },
""email"": { ""type"": ""string"", ""format"": ""email"" }
},
""required"": [""name"", ""age""]
}";

var json = @"{
""name"": ""John Doe"",
""age"": 30,
""email"": ""john@example.com""
}";

json.ShouldMatchJsonSchema(schema);
}

[Fact]
public void ShouldMatchJsonSchema_WithArraySchema_ShouldNotThrow()
{
var schema = @"{
""type"": ""array"",
""items"": {
""type"": ""object"",
""properties"": {
""id"": { ""type"": ""integer"" },
""name"": { ""type"": ""string"" }
},
""required"": [""id"", ""name""]
},
""minItems"": 1
}";

var json = @"[
{ ""id"": 1, ""name"": ""Item 1"" },
{ ""id"": 2, ""name"": ""Item 2"" }
]";

json.ShouldMatchJsonSchema(schema);
}

[Fact]
public void ShouldMatchJsonSchema_WithComplexValidation_ShouldNotThrow()
{
var schema = @"{
""type"": ""object"",
""properties"": {
""id"": { ""type"": ""integer"" },
""name"": {
""type"": ""string"",
""minLength"": 3,
""maxLength"": 50
},
""tags"": {
""type"": ""array"",
""items"": { ""type"": ""string"" },
""uniqueItems"": true,
""minItems"": 1
},
""metadata"": {
""type"": ""object"",
""additionalProperties"": { ""type"": ""string"" }
}
},
""required"": [""id"", ""name"", ""tags""]
}";

var json = @"{
""id"": 1,
""name"": ""Test Item"",
""tags"": [""tag1"", ""tag2""],
""metadata"": {
""created"": ""2024-01-01"",
""author"": ""John Doe""
}
}";

json.ShouldMatchJsonSchema(schema);
}

[Fact]
public void ShouldMatchJsonSchema_WithInvalidType_ShouldThrow()
{
var schema = @"{
""type"": ""object"",
""properties"": {
""age"": { ""type"": ""integer"" }
}
}";

var json = @"{ ""age"": ""not a number"" }";

var ex = Should.Throw<ShouldAssertException>(() => json.ShouldMatchJsonSchema(schema));

ex.Message.ShouldContain("integer");
}

[Fact]
public void ShouldMatchJsonSchema_WithMissingRequired_ShouldThrow()
{
var schema = @"{
""type"": ""object"",
""properties"": {
""name"": { ""type"": ""string"" }
},
""required"": [""name""]
}";

var json = @"{}";

var ex = Should.Throw<ShouldAssertException>(() => json.ShouldMatchJsonSchema(schema));

ex.Message.ShouldContain("required");
}

[Fact]
public void ShouldMatchJsonSchema_WithInvalidFormat_ShouldThrow()
{
var schema = @"{
""type"": ""object"",
""properties"": {
""email"": { ""type"": ""string"", ""format"": ""email"" }
}
}";

var json = @"{ ""email"": ""not-an-email"" }";

var ex = Should.Throw<ShouldAssertException>(() => json.ShouldMatchJsonSchema(schema));

ex.Message.ShouldContain("email");
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ShouldMatchJsonSchema_WithInvalidJson_ShouldThrow(string? json)
{
var schema = @"{ ""type"": ""object"" }";

Should.Throw<ShouldAssertException>(() => json.ShouldMatchJsonSchema(schema));
}

[Fact]
public void ShouldMatchJsonSchema_WithInvalidSchema_ShouldThrow()
{
var schema = "[]";
var json = "{}";

var ex = Should.Throw<ShouldAssertException>(() => json.ShouldMatchJsonSchema(schema));

ex.Message.ShouldContain("Invalid JSON Schema");
}

[Fact]
public void ShouldMatchJsonSchema_WithCustomMessage_ShouldIncludeMessage()
{
var schema = @"{ ""type"": ""string"" }";
var json = "42";
var customMessage = "Custom error message";

var ex = Should.Throw<ShouldAssertException>(() => json.ShouldMatchJsonSchema(schema, customMessage));

ex.Message.ShouldContain(customMessage);
}
}

0 comments on commit df95d25

Please sign in to comment.