diff --git a/Unity/Assets/Json/JsonSchema.cs b/Unity/Assets/Json/JsonSchema.cs index 3612f0f..ea69a37 100644 --- a/Unity/Assets/Json/JsonSchema.cs +++ b/Unity/Assets/Json/JsonSchema.cs @@ -1,7 +1,13 @@ #nullable enable +using System; +using System.Collections; using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using NeuroSdk.Actions; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace NeuroSdk.Json { @@ -61,65 +67,373 @@ public List Required set => _required = value; } - #region Keywords + internal sealed class ConstNull : JsonSchema + { + [JsonProperty("const", NullValueHandling = NullValueHandling.Include)] + public override object? Const { get; set; } + } + + #region Validation + + /// + /// Validate an ActionJData object against a JsonSchema + /// Returns false and the error message if invalid + /// + public bool ValidateSafe(ActionJData? obj, out string? message) + { + try + { + Validate(obj); + } + catch (Exception e) + { + message = e.Message; + return false; + } + + message = null; + return true; + } + + /// + /// Validate a JToken against a JsonSchema + /// Returns false and the error message if invalid + /// + public bool ValidateSafe(JToken? obj, out string? message) + { + try + { + Validate(obj); + } + catch (Exception e) + { + message = e.Message; + return false; + } + + message = null; + return true; + } + + /// + /// Validate an object (POCO, Dictionary, etc.) against a JsonSchema + /// Returns false and the error message if invalid + /// + public bool ValidateSafe(object? obj, out string? message) + { + try + { + Validate(obj); + } + catch (Exception e) + { + message = e.Message; + return false; + } + + message = null; + return true; + } + + /// + /// Validate an ActionJData object against a JsonSchema + /// Throws an exception if invalid + /// + public void Validate(ActionJData? actionJData) + { + Validate(actionJData, ""); + } + + private void Validate(ActionJData? actionData, string path) + { + if (actionData == null) throw new Exception($"{path}: expected action data"); + + var token = actionData.Data; - [JsonProperty("properties")] - private Dictionary? _properties; + Validate(token, path); + } - [JsonProperty("items")] - public JsonSchema? Items { get; set; } + /// + /// Validate a JToken against a JsonSchema + /// Throws an exception if invalid + /// + public void Validate(JToken? token) + { + Validate(token, ""); + } - [JsonProperty("type")] - private string? _type; + private void Validate(JToken? token, string path) + { + if (token == null || token.Type == JTokenType.Null) + { + if (Type == JsonSchemaType.Null /* || schema.Const == null */) return; + throw new Exception($"{path}: value is null but schema does not allow null"); + } - [JsonProperty("enum")] - private List? _enum; + var obj = UnwrapToken(token); - [JsonProperty("const")] - public virtual object? Const { get; set; } + Validate(obj, path); + } - [JsonProperty("minLength")] - public int? MinLength { get; set; } + /// + /// Validate an object (POCO, Dictionary, etc.) against a JsonSchema + /// Throws an exception if invalid + /// + public void Validate(object? obj) + { + Validate(obj, ""); + } - [JsonProperty("pattern")] - public string? Pattern { get; set; } + private void Validate(object? obj, string path) + { + if (obj == null) + { + // How do I check if the schema.Const is explicitly null? + // Welp, I just won't check for that I guess. + if (Type == JsonSchemaType.Null /* || schema.Const == null */) return; + throw new Exception($"{path}: value is null but schema does not allow null"); + } - [JsonProperty("maxLength")] - public int? MaxLength { get; set; } + if (Const != null && !Const.Equals(obj)) + throw new Exception($"{path}: value must be constant {Const}"); - [JsonProperty("maximum")] - public float? Maximum { get; set; } + if (Enum is { Count: > 0 } && !Enum.Contains(obj)) + throw new Exception($"{path}: value must be one of [{string.Join(", ", Enum)}]"); - [JsonProperty("exclusiveMinimum")] - public float? ExclusiveMinimum { get; set; } - [JsonProperty("exclusiveMaximum")] - public float? ExclusiveMaximum { get; set; } + switch (Type) + { + case JsonSchemaType.String: + ValidateString(obj, path); + break; + case JsonSchemaType.Float: + ValidateFloat(obj, path); + break; + case JsonSchemaType.Integer: + ValidateInteger(obj, path); + break; + case JsonSchemaType.Object: + ValidateObject(obj, path); + break; + case JsonSchemaType.Array: + ValidateArray(obj, path); + break; + case JsonSchemaType.Boolean: + ValidateBoolean(obj, path); + break; + case JsonSchemaType.Null: + ValidateNull(obj, path); + break; + case JsonSchemaType.None: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } - [JsonProperty("minimum")] - public float? Minimum { get; set; } + private void ValidateString(object obj, string path) + { + if (obj is not string s) + throw new Exception($"{path}: expected string"); - [JsonProperty("required")] - private List? _required; + if (MinLength.HasValue && s.Length < MinLength.Value) + throw new Exception($"{path}: string too short (min {MinLength.Value})"); - [JsonProperty("minItems")] - public int? MinItems { get; set; } + if (MaxLength.HasValue && s.Length > MaxLength.Value) + throw new Exception($"{path}: string too long (max {MaxLength.Value})"); - [JsonProperty("maxItems")] - public int? MaxItems { get; set; } + if (string.IsNullOrEmpty(Pattern)) return; - [JsonProperty("uniqueItems")] - public bool? UniqueItems { get; set; } + if (Pattern != null && !Regex.IsMatch(s, Pattern)) + throw new Exception($"{path}: string does not match pattern {Pattern}"); + } - [JsonProperty("format")] - public string? Format { get; set; } + private void ValidateFloat(object obj, string path) + { + switch (obj) + { + case float f: + ValidateNumber(f, path); + break; + case double d: + ValidateNumber(d, path); + break; + case int i: + ValidateNumber(i, path); + break; + default: + throw new Exception($"{path}: expected float"); + } + } - #endregion - - internal sealed class ConstNull : JsonSchema + private void ValidateInteger(object obj, string path) { - [JsonProperty("const", NullValueHandling = NullValueHandling.Include)] - public override object? Const { get; set; } = null; + switch (obj) + { + case int i: + ValidateNumber(i, path); + break; + case long l: + ValidateNumber(l, path); + break; + default: + throw new Exception($"{path}: expected integer"); + } + } + + private void ValidateNumber(double value, string path) + { + if (value < Minimum) + throw new Exception($"{path}: value {value} < minimum {Minimum.Value}"); + if (value > Maximum) + throw new Exception($"{path}: value {value} > maximum {Maximum.Value}"); + if (value <= ExclusiveMinimum) + throw new Exception($"{path}: value {value} <= exclusive minimum {ExclusiveMinimum.Value}"); + if (value >= ExclusiveMaximum) + throw new Exception($"{path}: value {value} >= exclusive maximum {ExclusiveMaximum.Value}"); + } + + private void ValidateObject(object obj, string path) + { + if (obj is not IDictionary dict) + throw new Exception($"{path}: expected object"); + + + foreach (var req in Required.Where(req => !dict.ContainsKey(req))) + throw new Exception($"{MakePath(path, req)}: missing required property"); + + foreach (var kvp in dict) + if (!Properties.TryGetValue(kvp.Key, out var subSchema)) + { + if (!AdditionalProperties) + throw new Exception($"{MakePath(path, kvp.Key)}: unknown property not allowed"); + } + else + { + subSchema.Validate(kvp.Value, $"{MakePath(path, kvp.Key)}"); + } + + return; + + string MakePath(string parentPath, string key) + { + if (string.IsNullOrEmpty(parentPath)) + return key; + return parentPath + "." + key; + } + } + + private void ValidateArray(object obj, string path) + { + if (obj is not IEnumerable enumerable) + throw new Exception($"{path}: expected array"); + + var list = enumerable.Cast().ToList(); + + if (MinItems.HasValue && list.Count < MinItems.Value) + throw new Exception( + $"{path}: array item count must be at least {MinItems.Value}" + ); + + if (MaxItems.HasValue && list.Count > MaxItems.Value) + throw new Exception( + $"{path}: array item count must be at most {MaxItems.Value}" + ); + + if (UniqueItems == true && list.Distinct().Count() != list.Count) + throw new Exception( + $"{path}: array items must be unique" + ); + + + if (Items == null) return; + + for (var i = 0; i < list.Count; i++) + Items.Validate(list[i], $"{path}[{i}]"); + } + + private void ValidateBoolean(object obj, string path) + { + if (obj is not bool) + throw new Exception($"{path}: expected boolean, got {obj.GetType().Name}"); + } + + private void ValidateNull(object obj, string path) + { + if (obj is not null) throw new Exception($"{path}: expected null"); + } + + private static object? UnwrapToken(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + return token.Type switch + { + JTokenType.Object => UnwrapJObject((JObject)token), + JTokenType.Array => UnwrapJArray((JArray)token), + JTokenType.Integer => token.Value(), + JTokenType.Float => token.Value(), + JTokenType.Boolean => token.Value(), + JTokenType.String => token.Value(), + _ => throw new Exception($"Unsupported token type {token.Type}") + }; + } + + private static Dictionary UnwrapJObject(JObject jObj) + { + return jObj.Properties() + .ToDictionary( + p => p.Name, + p => UnwrapToken(p.Value) + ); + } + + private static List UnwrapJArray(JArray jArr) + { + return jArr.Select(UnwrapToken).ToList(); } + + #endregion + + #region Keywords + + [JsonProperty("properties")] private Dictionary? _properties; + + [JsonProperty("items")] public JsonSchema? Items { get; set; } + + [JsonProperty("type")] private string? _type; + + [JsonProperty("enum")] private List? _enum; + + [JsonProperty("const")] public virtual object? Const { get; set; } + + [JsonProperty("minLength")] public int? MinLength { get; set; } + + [JsonProperty("pattern")] public string? Pattern { get; set; } + + [JsonProperty("maxLength")] public int? MaxLength { get; set; } + + [JsonProperty("maximum")] public float? Maximum { get; set; } + + [JsonProperty("exclusiveMinimum")] public float? ExclusiveMinimum { get; set; } + + [JsonProperty("exclusiveMaximum")] public float? ExclusiveMaximum { get; set; } + + [JsonProperty("minimum")] public float? Minimum { get; set; } + + [JsonProperty("required")] private List? _required; + + [JsonProperty("minItems")] public int? MinItems { get; set; } + + [JsonProperty("maxItems")] public int? MaxItems { get; set; } + + [JsonProperty("uniqueItems")] public bool? UniqueItems { get; set; } + + [JsonProperty("format")] public string? Format { get; set; } + + [JsonProperty("additionalProperties")] public bool AdditionalProperties { get; set; } + + #endregion } -} +} \ No newline at end of file diff --git a/Unity/Assets/Json/QJS.cs b/Unity/Assets/Json/QJS.cs index d61504f..a2592da 100644 --- a/Unity/Assets/Json/QJS.cs +++ b/Unity/Assets/Json/QJS.cs @@ -52,12 +52,13 @@ public static JsonSchema Type(JsonSchemaType type) }; } - public static JsonSchema WrapObject(IReadOnlyDictionary properties, bool makePropertiesRequired = true) + public static JsonSchema WrapObject(IReadOnlyDictionary properties, bool makePropertiesRequired = true, bool allowAdditionalProperties = true) { JsonSchema result = new() { Type = JsonSchemaType.Object, - Properties = properties.ToDictionary(x => x.Key, x => x.Value) + Properties = properties.ToDictionary(x => x.Key, x => x.Value), + AdditionalProperties = allowAdditionalProperties }; if (makePropertiesRequired) result.Required = properties.Keys.ToList();