Skip to content
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
182 changes: 182 additions & 0 deletions Core/Resgrid.Framework/JsonInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using System.Text.Json;

namespace Resgrid.Framework
{
/// <summary>Strict validation for JSON supplied by a person, before Json.NET can coerce field types.</summary>
public static class JsonInput
{
private static readonly DefaultContractResolver Contracts = new DefaultContractResolver();
public const int MaximumLength = 2 * 1024 * 1024;

public static T Read<T>(string json, string field = "JSON")
{
if (string.IsNullOrWhiteSpace(json)) throw new JsonInputException(field + ": enter JSON using the example shown on this form.");
if (json.Length > MaximumLength) throw new JsonInputException(field + ": reduce the JSON to less than 2 MB.");
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 64 });
var errors = new List<string>();
Validate(document.RootElement, typeof(T), "$", errors, false);
if (errors.Count > 0) throw new JsonInputException(field + ": " + string.Join(" ", errors));
return JsonConvert.DeserializeObject<T>(json, new JsonSerializerSettings { MaxDepth = 64, TypeNameHandling = TypeNameHandling.None, DateParseHandling = DateParseHandling.None });
}
catch (System.Text.Json.JsonException ex)
{
throw new JsonInputException($"{field}: invalid JSON at line {ex.LineNumber + 1}, byte {ex.BytePositionInLine + 1}. Check double quotes, commas, brackets and braces; comments and trailing commas are not allowed.");
}
catch (Newtonsoft.Json.JsonException ex)
{
var path = (ex as JsonSerializationException)?.Path;
throw new JsonInputException($"{field}: {path ?? "$"} cannot be read. Use the field type and format shown in the schema.");
}
}

private static void Validate(JsonElement value, Type type, string path, List<string> errors, bool allowNull)
{
if (errors.Count >= 20) return;
var underlying = Nullable.GetUnderlyingType(type);
if (value.ValueKind == JsonValueKind.Null)
{
if (!allowNull && underlying == null) errors.Add(path + ": null is not allowed; supply a value of the documented type.");
return;
}
type = underlying ?? type;
var contract = Contracts.ResolveContract(type);
if (contract is JsonDictionaryContract dictionary)
{
if (!Expect(value, JsonValueKind.Object, path, "an object { ... }", errors)) return;
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in value.EnumerateObject())
{
if (errors.Count >= 20) break;
var entryPath = path + "[" + JsonConvert.ToString(entry.Name) + "]";
if (!names.Add(entry.Name)) errors.Add(entryPath + ": duplicate field; keep only one value.");
if (dictionary.DictionaryKeyType == typeof(int) && (!int.TryParse(entry.Name, NumberStyles.None, CultureInfo.InvariantCulture, out var key) || key < 1))
errors.Add(entryPath + ": use a positive whole-number version as the key, for example \"1\".");
Validate(entry.Value, dictionary.DictionaryValueType, entryPath, errors, false);
}
}
else if (contract is JsonArrayContract array)
{
if (!Expect(value, JsonValueKind.Array, path, "an array [ ... ]", errors)) return;
var index = 0;
foreach (var item in value.EnumerateArray()) Validate(item, array.CollectionItemType, path + "[" + index++ + "]", errors, false);
}
else if (contract is JsonObjectContract obj)
{
if (!Expect(value, JsonValueKind.Object, path, "an object { ... }", errors)) return;
var properties = obj.Properties.Where(p => !p.Ignored && p.Writable).ToList();
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in value.EnumerateObject())
{
if (errors.Count >= 20) break;
if (!names.Add(entry.Name)) errors.Add(path + "." + entry.Name + ": duplicate field; keep only one value.");
var property = properties.FirstOrDefault(p => string.Equals(p.PropertyName, entry.Name, StringComparison.OrdinalIgnoreCase));
if (property == null) { errors.Add(path + "." + entry.Name + ": unknown field; remove it or use a field name from the schema."); continue; }
Validate(entry.Value, property.PropertyType, path + "." + entry.Name, errors, AllowsNull(property));
}
foreach (var property in properties)
{
var required = property.AttributeProvider.GetAttributes(typeof(RequiredAttribute), true).Any();
if (required && !value.EnumerateObject().Any(p => string.Equals(p.Name, property.PropertyName, StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind != JsonValueKind.Null && (p.Value.ValueKind != JsonValueKind.String || !string.IsNullOrWhiteSpace(p.Value.GetString()))))
errors.Add(path + "." + property.PropertyName + ": required; supply a non-empty value.");
}
}
else if (type.IsEnum)
{
var valid = value.ValueKind == JsonValueKind.String && Enum.GetNames(type).Contains(value.GetString());
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) valid = Enum.IsDefined(type, number);
if (!valid) errors.Add(path + ": choose one of " + string.Join(", ", Enum.GetNames(type)) + " (or its integer value in the schema).");
}
else if (type == typeof(string)) Expect(value, JsonValueKind.String, path, "a string in double quotes", errors);
else if (type == typeof(bool))
{
if (value.ValueKind != JsonValueKind.True && value.ValueKind != JsonValueKind.False) errors.Add(path + ": expected true or false, without quotes.");
}
else if (type == typeof(DateTime) || type == typeof(DateTimeOffset))
{
if (value.ValueKind != JsonValueKind.String || !value.TryGetDateTime(out _)) errors.Add(path + ": use an ISO date such as \"2026-09-22\" or date/time such as \"2026-09-22T14:30:00Z\".");
}
else if (type == typeof(int) || type == typeof(long))
{
if (value.ValueKind != JsonValueKind.Number || (type == typeof(int) ? !value.TryGetInt32(out _) : !value.TryGetInt64(out _))) errors.Add(path + ": expected a whole number within " + (type == typeof(int) ? "-2147483648 to 2147483647" : "the 64-bit integer range") + ", without quotes or decimals.");
}
else if (type == typeof(decimal) || type == typeof(double) || type == typeof(float))
{
if (value.ValueKind != JsonValueKind.Number || !value.TryGetDecimal(out _)) errors.Add(path + ": expected a number such as 12.50, without quotes, currency symbols or thousands separators.");
}
else throw new InvalidOperationException("Unsupported JSON input type: " + type.FullName);
}

private static bool AllowsNull(Newtonsoft.Json.Serialization.JsonProperty property) => !property.PropertyType.IsValueType
&& property.Required != Required.DisallowNull && property.Required != Required.Always
&& !property.AttributeProvider.GetAttributes(typeof(RequiredAttribute), true).Any();

private static bool Expect(JsonElement value, JsonValueKind kind, string path, string expected, List<string> errors)
{
if (value.ValueKind == kind) return true;
errors.Add(path + ": expected " + expected + "; received " + value.ValueKind.ToString().ToLowerInvariant() + ".");
return false;
}

/// <summary>Documentation uses the same CLR contracts as validation, including recursive record rules.</summary>
public static string Schema<T>()
{
var definitions = new JObject();
var schema = Describe(typeof(T), definitions, false);
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema";
schema["$defs"] = definitions;
return schema.ToString(Formatting.Indented);
}

private static JObject Describe(Type type, JObject definitions, bool allowNull)
{
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null || allowNull) return new JObject { ["anyOf"] = new JArray(Describe(underlying ?? type, definitions, false), new JObject { ["type"] = "null" }) };
var contract = Contracts.ResolveContract(type);
if (contract is JsonDictionaryContract dictionary)
{
var result = new JObject { ["type"] = "object", ["additionalProperties"] = Describe(dictionary.DictionaryValueType, definitions, false) };
if (dictionary.DictionaryKeyType == typeof(int)) result["propertyNames"] = new JObject { ["pattern"] = "^[1-9][0-9]*$" };
return result;
}
if (contract is JsonArrayContract array) return new JObject { ["type"] = "array", ["items"] = Describe(array.CollectionItemType, definitions, false) };
if (contract is JsonObjectContract obj)
{
var key = type.Name;
if (definitions[key] == null)
{
var properties = new JObject();
var required = new JArray();
definitions[key] = new JObject { ["type"] = "object", ["additionalProperties"] = false, ["properties"] = properties, ["required"] = required };
foreach (var property in obj.Properties.Where(p => !p.Ignored && p.Writable))
{
var isRequired = property.AttributeProvider.GetAttributes(typeof(RequiredAttribute), true).Any();
properties[property.PropertyName] = Describe(property.PropertyType, definitions, AllowsNull(property));
if (isRequired) { required.Add(property.PropertyName); if (property.PropertyType == typeof(string)) properties[property.PropertyName]["minLength"] = 1; }
}
}
return new JObject { ["$ref"] = "#/$defs/" + key };
}
if (type.IsEnum) return new JObject { ["enum"] = new JArray(Enum.GetNames(type).Cast<object>().Concat(Enum.GetValues(type).Cast<object>().Select(v => (object)Convert.ToInt32(v)))) };
if (type == typeof(string)) return new JObject { ["type"] = "string" };
if (type == typeof(bool)) return new JObject { ["type"] = "boolean" };
if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) return new JObject { ["type"] = "string", ["description"] = "ISO 8601 date or date/time, e.g. 2026-09-22 or 2026-09-22T14:30:00Z" };
if (type == typeof(int) || type == typeof(long)) return new JObject { ["type"] = "integer", ["minimum"] = type == typeof(int) ? int.MinValue : long.MinValue, ["maximum"] = type == typeof(int) ? int.MaxValue : long.MaxValue };
return new JObject { ["type"] = "number" };
}
}

public sealed class JsonInputException : InvalidOperationException
{
public JsonInputException(string message) : base(message) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>تمت إزالة شهادة وحدة</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>تمت إزالة شهادة</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>تمت إضافة ساعات اعتماد شهادة</value></data>
<data name="AddCertification" xml:space="preserve"><value>إضافة شهادة</value></data>
<data name="SelectMember" xml:space="preserve"><value>اختر عضوًا</value></data>
<data name="SelectType" xml:space="preserve"><value>اختر نوع الشهادة</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>أدخل اسم الشهادة.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Einheitszertifizierung entfernt</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Zertifizierung entfernt</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Fortbildungspunkte hinzugefügt</value></data>
<data name="AddCertification" xml:space="preserve"><value>Zertifizierung hinzufügen</value></data>
<data name="SelectMember" xml:space="preserve"><value>Mitglied auswählen</value></data>
<data name="SelectType" xml:space="preserve"><value>Zertifizierungstyp auswählen</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Geben Sie einen Namen für die Zertifizierung ein.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Αφαιρέθηκε πιστοποίηση μονάδας</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Αφαιρέθηκε πιστοποίηση</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Προστέθηκαν μονάδες πιστοποίησης</value></data>
<data name="AddCertification" xml:space="preserve"><value>Προσθήκη πιστοποίησης</value></data>
<data name="SelectMember" xml:space="preserve"><value>Επιλέξτε μέλος</value></data>
<data name="SelectType" xml:space="preserve"><value>Επιλέξτε τύπο πιστοποίησης</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Εισαγάγετε όνομα πιστοποίησης.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Unit certification removed</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Certification removed</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Certification credit added</value></data>
<data name="AddCertification" xml:space="preserve"><value>Add Certification</value></data>
<data name="SelectMember" xml:space="preserve"><value>Select a member</value></data>
<data name="SelectType" xml:space="preserve"><value>Select a certification type</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Enter a certification name.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Certificación de unidad eliminada</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Certificación eliminada</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Créditos de certificación añadidos</value></data>
<data name="AddCertification" xml:space="preserve"><value>Añadir certificación</value></data>
<data name="SelectMember" xml:space="preserve"><value>Seleccione un miembro</value></data>
<data name="SelectType" xml:space="preserve"><value>Seleccione un tipo de certificación</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Introduzca un nombre para la certificación.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Certification d'unité supprimée</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Certification supprimée</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Crédits de certification ajoutés</value></data>
<data name="AddCertification" xml:space="preserve"><value>Ajouter une certification</value></data>
<data name="SelectMember" xml:space="preserve"><value>Sélectionnez un membre</value></data>
<data name="SelectType" xml:space="preserve"><value>Sélectionnez un type de certification</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Saisissez un nom de certification.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Certificazione unità rimossa</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Certificazione rimossa</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Crediti di certificazione aggiunti</value></data>
<data name="AddCertification" xml:space="preserve"><value>Aggiungi certificazione</value></data>
<data name="SelectMember" xml:space="preserve"><value>Seleziona un membro</value></data>
<data name="SelectType" xml:space="preserve"><value>Seleziona un tipo di certificazione</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Inserisci un nome per la certificazione.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Usunięto certyfikat jednostki</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Usunięto certyfikat</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Dodano punkty certyfikacji</value></data>
<data name="AddCertification" xml:space="preserve"><value>Dodaj certyfikat</value></data>
<data name="SelectMember" xml:space="preserve"><value>Wybierz członka</value></data>
<data name="SelectType" xml:space="preserve"><value>Wybierz typ certyfikatu</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Wprowadź nazwę certyfikatu.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Unit certification removed</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Certification removed</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Certification credit added</value></data>
<data name="AddCertification" xml:space="preserve"><value>Add Certification</value></data>
<data name="SelectMember" xml:space="preserve"><value>Select a member</value></data>
<data name="SelectType" xml:space="preserve"><value>Select a certification type</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Enter a certification name.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Enhetscertifiering borttagen</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Certifiering borttagen</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Certifieringspoäng tillagda</value></data>
<data name="AddCertification" xml:space="preserve"><value>Lägg till certifiering</value></data>
<data name="SelectMember" xml:space="preserve"><value>Välj en medlem</value></data>
<data name="SelectType" xml:space="preserve"><value>Välj en certifieringstyp</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Ange ett namn på certifieringen.</value></data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,8 @@
<data name="UnitCertificationRemoved" xml:space="preserve"><value>Вилучено сертифікацію підрозділу</value></data>
<data name="CertificationRemoved" xml:space="preserve"><value>Вилучено сертифікацію</value></data>
<data name="CertificationCreditAdded" xml:space="preserve"><value>Додано кредити сертифікації</value></data>
<data name="AddCertification" xml:space="preserve"><value>Додати сертифікацію</value></data>
<data name="SelectMember" xml:space="preserve"><value>Виберіть учасника</value></data>
<data name="SelectType" xml:space="preserve"><value>Виберіть тип сертифікації</value></data>
<data name="CertificationNameRequired" xml:space="preserve"><value>Введіть назву сертифікації.</value></data>
</root>
Loading
Loading