Skip to content

Vocab data v2 #316

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 12 commits into from
Aug 25, 2022
60 changes: 47 additions & 13 deletions JsonSchema.Data.Tests/Tests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema.Tests;
using NUnit.Framework;

Expand All @@ -7,17 +8,32 @@ namespace Json.Schema.Data.Tests;
public class Tests
{
private static JsonSchema InstanceRef { get; } = new JsonSchemaBuilder()
.Schema("https://gregsdennis.github.io/json-everything/meta/data")
.Schema("https://json-everything.net/meta/data-2022")
.Type(SchemaValueType.Object)
.Properties(
("foo", new JsonSchemaBuilder()
.Type(SchemaValueType.Integer)
.Data(("minimum", "#/minValue"))
.Data(("minimum", "/minValue"))
)
);

private static JsonSchema InstanceRelativeRef { get; } = new JsonSchemaBuilder()
.Schema("https://json-everything.net/meta/data-2022")
.Type(SchemaValueType.Object)
.Properties(
("foo", new JsonSchemaBuilder()
.Type(SchemaValueType.Object)
.Properties(
("bar", new JsonSchemaBuilder()
.Type(SchemaValueType.Integer)
.Data(("minimum", "2/minValue"))
)
)
)
);

private static JsonSchema ExternalRef { get; } = new JsonSchemaBuilder()
.Schema("https://gregsdennis.github.io/json-everything/meta/data")
.Schema("https://json-everything.net/meta/data-2022")
.Type(SchemaValueType.Object)
.Properties(
("foo", new JsonSchemaBuilder()
Expand Down Expand Up @@ -57,33 +73,51 @@ public void InstanceRef_Failing()
}

[Test]
public void InstanceRef_InvalidValueType()
public void InstanceRelativeRef_Passing()
{
var instanceData = "{\"minValue\":true,\"foo\":10}";
var instanceData = "{\"minValue\":5,\"foo\":{\"bar\":10}}";
var instance = JsonDocument.Parse(instanceData).RootElement;

var result = InstanceRef.Validate(instance);
var result = InstanceRelativeRef.Validate(instance);

result.AssertValid();
}

[Test]
public void InstanceRelativeRef_Failing()
{
var instanceData = "{\"minValue\":15,\"foo\":{\"bar\":10}}";
var instance = JsonDocument.Parse(instanceData).RootElement;

var result = InstanceRelativeRef.Validate(instance);

result.AssertInvalid();
}

[Test]
public void InstanceRef_InvalidValueType()
{
var instanceData = "{\"minValue\":true,\"foo\":10}";
var instance = JsonDocument.Parse(instanceData).RootElement;

Assert.Throws<JsonException>(() => InstanceRef.Validate(instance));
}

[Test]
public void InstanceRef_Unresolvable()
{
var instanceData = "{\"minValu\":5,\"foo\":10}";
var instance = JsonDocument.Parse(instanceData).RootElement;

var result = InstanceRef.Validate(instance);

result.AssertInvalid();
Assert.Throws<RefResolutionException>(() => InstanceRef.Validate(instance));
}

[Test]
public void ExternalRef_Passing()
{
try
{
DataKeyword.Get = _ => "{\"minValue\":5}";
DataKeyword.Fetch = _ => JsonNode.Parse("{\"minValue\":5}");

var instanceData = "{\"foo\":10}";
var instance = JsonDocument.Parse(instanceData).RootElement;
Expand All @@ -94,7 +128,7 @@ public void ExternalRef_Passing()
}
finally
{
DataKeyword.Get = null!;
DataKeyword.Fetch = null!;
}
}

Expand All @@ -103,7 +137,7 @@ public void ExternalRef_Failing()
{
try
{
DataKeyword.Get = _ => "{\"minValue\":15}";
DataKeyword.Fetch = _ => JsonNode.Parse("{\"minValue\":15}");

var instanceData = "{\"foo\":10}";
var instance = JsonDocument.Parse(instanceData).RootElement;
Expand All @@ -114,7 +148,7 @@ public void ExternalRef_Failing()
}
finally
{
DataKeyword.Get = null!;
DataKeyword.Fetch = null!;
}
}
}
127 changes: 38 additions & 89 deletions JsonSchema.Data/DataKeyword.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Json.Pointer;

namespace Json.Schema.Data;

Expand All @@ -23,31 +23,36 @@ public class DataKeyword : IJsonSchemaKeyword, IEquatable<DataKeyword>
{
internal const string Name = "data";

private static Func<Uri, string>? _get;

/// <summary>
/// Gets or sets a method to download external references.
/// </summary>
/// <remarks>
/// The default method simply attempts to download the resource. There is no
/// caching involved.
/// </remarks>
public static Func<Uri, string> Get
{
get => _get ??= SimpleDownload;
set => _get = value;
}
public static Func<Uri, JsonNode?>? Fetch { get; set; }

/// <summary>
/// Provides a registry for known external data sources.
/// </summary>
/// <remarks>
/// This property stores full JSON documents retrievable by URI. If the desired
/// value exists as a sub-value of a document, a JSON Pointer URI fragment identifier
/// should be used in the `data` keyword do identify the exact value location.
/// </remarks>
public static ConcurrentDictionary<Uri, JsonValue?> ExternalDataRegistry { get; } = new();


/// <summary>
/// The collection of keywords and references.
/// </summary>
public IReadOnlyDictionary<string, Uri> References { get; }
public IReadOnlyDictionary<string, IDataResourceIdentifier> References { get; }

/// <summary>
/// Creates an instance of the <see cref="DataKeyword"/> class.
/// </summary>
/// <param name="references">The collection of keywords and references.</param>
public DataKeyword(IReadOnlyDictionary<string, Uri> references)
public DataKeyword(IReadOnlyDictionary<string, IDataResourceIdentifier> references)
{
References = references;
}
Expand All @@ -56,100 +61,44 @@ public DataKeyword(IReadOnlyDictionary<string, Uri> references)
/// Provides validation for the keyword.
/// </summary>
/// <param name="context">Contextual details for the validation process.</param>
/// <exception cref="JsonException">
/// Thrown when the formed schema contains values that are invalid for the associated
/// keywords.
/// </exception>
public void Validate(ValidationContext context)
{
context.EnterKeyword(Name);
var data = new Dictionary<string, JsonNode>();
var failedReferences = new List<IDataResourceIdentifier>();
foreach (var reference in References)
{
if (!TryResolve(context, reference.Value, out var resolved)) return;
if (!reference.Value.TryResolve(context, out var resolved))
failedReferences.Add(reference.Value);

data.Add(reference.Key, resolved!);
}

var json = JsonSerializer.Serialize(data);
JsonSchema subschema;
try
{
subschema = JsonSerializer.Deserialize<JsonSchema>(json)!;
}
catch (JsonException e)
if (failedReferences.Any())
{
context.LocalResult.Fail(e.Message);
return;
throw new RefResolutionException(failedReferences.Select(x => x.ToString()));
}

var json = JsonSerializer.Serialize(data);
var subschema = JsonSerializer.Deserialize<JsonSchema>(json)!;

subschema.ValidateSubschema(context);
context.ExitKeyword(Name);
}

private static bool TryResolve(ValidationContext context, Uri target, out JsonNode? node)
{
var parts = target.OriginalString.Split(new[] { '#' }, StringSplitOptions.None);
var baseUri = parts[0];
var fragment = parts.Length > 1 ? parts[1] : null;

JsonNode? data;
if (!string.IsNullOrEmpty(baseUri))
{
bool wasResolved;
if (Uri.TryCreate(baseUri, UriKind.Absolute, out var newUri))
wasResolved = TryDownload(newUri, out data);
else
{
var uriFolder = context.CurrentUri.OriginalString.EndsWith("/")
? context.CurrentUri
: context.CurrentUri.GetParentUri();
var newBaseUri = new Uri(uriFolder, baseUri);
wasResolved = TryDownload(newBaseUri, out data);
}

if (!wasResolved)
{
context.LocalResult.Fail(ErrorMessages.BaseUriResolution, ("uri", baseUri));
node = null;
return false;
}
}
else
data = context.InstanceRoot;

if (!string.IsNullOrEmpty(fragment))
{
fragment = $"#{fragment}";
if (!JsonPointer.TryParse(fragment, out var pointer))
{
context.LocalResult.Fail(ErrorMessages.PointerParse, ("fragment", fragment));
node = null;
return false;
}

if (!pointer!.TryEvaluate(data, out var resolved))
{
context.LocalResult.Fail(ErrorMessages.RefResolution, ("uri", fragment));
node = null;
return false;
}
data = resolved;
}

node = data;
return true;
}

private static bool TryDownload(Uri uri, out JsonNode? node)
{
var data = Get(uri);
if (data == null)
{
node = null;
return false;
}
node = JsonNode.Parse(data);
return true;
}

private static string SimpleDownload(Uri uri)
/// <summary>
/// Provides a simple data fetch method that supports `http`, `https`, and `file` URI schemes.
/// </summary>
/// <param name="uri">The URI to fetch.</param>
/// <returns>A JSON string representing the data</returns>
/// <exception cref="FormatException">
/// Thrown when the URI scheme is not `http`, `https`, or `file`.
/// </exception>
public static JsonNode? SimpleDownload(Uri uri)
{
switch (uri.Scheme)
{
Expand All @@ -160,7 +109,7 @@ private static string SimpleDownload(Uri uri)
var filename = Uri.UnescapeDataString(uri.AbsolutePath);
return File.ReadAllText(filename);
default:
throw new Exception($"URI scheme '{uri.Scheme}' is not supported. Only HTTP(S) and local file system URIs are allowed.");
throw new FormatException($"URI scheme '{uri.Scheme}' is not supported. Only HTTP(S) and local file system URIs are allowed.");
}
}

Expand Down Expand Up @@ -206,7 +155,7 @@ public override DataKeyword Read(ref Utf8JsonReader reader, Type typeToConvert,
throw new JsonException("Expected object");

var references = JsonSerializer.Deserialize<Dictionary<string, string>>(ref reader, options)!
.ToDictionary(kvp => kvp.Key, kvp => new Uri(kvp.Value, UriKind.RelativeOrAbsolute));
.ToDictionary(kvp => kvp.Key, kvp => JsonSchemaBuilderExtensions.CreateResourceIdentifier(kvp.Value));
return new DataKeyword(references);
}

Expand Down
17 changes: 17 additions & 0 deletions JsonSchema.Data/IDataResourceIdentifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json.Nodes;

namespace Json.Schema.Data;

/// <summary>
/// Provides an abstraction for different resource identifier types.
/// </summary>
public interface IDataResourceIdentifier
{
/// <summary>
/// Attempts to resolve the reference.
/// </summary>
/// <param name="context">The schema evaluation context.</param>
/// <param name="value">If return is true, the value at the indicated location.</param>
/// <returns>true if resolution is successful; false otherwise.</returns>
bool TryResolve(ValidationContext context, out JsonNode? value);
}
42 changes: 42 additions & 0 deletions JsonSchema.Data/JsonPointerIdentifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Text.Json.Nodes;
using Json.Pointer;

namespace Json.Schema.Data;

/// <summary>
/// Handles data references that are JSON Pointers.
/// </summary>
public class JsonPointerIdentifier : IDataResourceIdentifier
{
/// <summary>
/// The JSON Pointer target.
/// </summary>
public JsonPointer Target { get; }

/// <summary>
/// Creates a new instance of <see cref="JsonPointerIdentifier"/>.
/// </summary>
/// <param name="target">The target.</param>
public JsonPointerIdentifier(JsonPointer target)
{
Target = target;
}

/// <summary>
/// Attempts to resolve the reference.
/// </summary>
/// <param name="context">The schema evaluation context.</param>
/// <param name="value">If return is true, the value at the indicated location.</param>
/// <returns>true if resolution is successful; false otherwise.</returns>
public bool TryResolve(ValidationContext context, out JsonNode? value)
{
return Target.TryEvaluate(context.InstanceRoot, out value);
}

/// <summary>Returns a string that represents the current object.</summary>
/// <returns>A string that represents the current object.</returns>
public override string ToString()
{
return Target.ToString();
}
}
Loading