// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Collections.Specialized; using System.Collections; using System.IO; using System.Text; using System.Text.Encodings.Web; namespace EpicGames.Core { using SystemJsonObject = System.Text.Json.Nodes.JsonObject; using SystemJsonNode = System.Text.Json.Nodes.JsonNode; using SystemJsonValue = System.Text.Json.Nodes.JsonValue; using SystemJsonArray = System.Text.Json.Nodes.JsonArray; /// /// Extension methods for OrderedDictionary to have functionality more similar to Dictionary. /// public static class OrderedDictionaryExtensions { /// /// Tries to get a value associated with a key in the OrderedDictionary. /// /// /// The key to find a value for. /// The value associated with the key. Is null if no key is found. /// Returns true if the associated value with the key exists and is found. Else returns false. public static bool TryGetValue(this OrderedDictionary dictionary, object key, [NotNullWhen(true)] out object? value) { if (dictionary.Contains(key)) { value = dictionary[key]!; return true; } value = null; return false; } private static object? DeepCopyHelper(object? original) { if (original is null) { return null; } if (original is OrderedDictionary dictionary) { OrderedDictionary copy = new OrderedDictionary(StringComparer.InvariantCultureIgnoreCase); foreach (DictionaryEntry entry in dictionary) { copy.Add(entry.Key, DeepCopyHelper(entry.Value)); } return copy; } if (original is Array array) { Array copy = Array.CreateInstance(array.GetType().GetElementType()!, array.Length); for (int index = 0; index < array.Length; ++index) { copy.SetValue(DeepCopyHelper(array.GetValue(index)), index); } return copy; } // It's a value type or a string, it's safe to just copy it return original; } /// /// Creates a deep copy of all the elements in an OrderedDictionary. /// /// /// An ordered dictionary with a deep copy of elements from dictionary. public static OrderedDictionary CreateDeepCopy(this OrderedDictionary dictionary) { OrderedDictionary copy = new OrderedDictionary(StringComparer.InvariantCultureIgnoreCase); foreach (DictionaryEntry entry in dictionary) { copy.Add(entry.Key, DeepCopyHelper(entry.Value)); } return copy; } } /// /// Stores a JSON object in memory /// public class JsonObject { readonly OrderedDictionary _rawOrderedObject; /// /// Default constructor. Use this to create new JsonObject elements and use the AddOrSetFieldValue API to add values to this JsonObject. /// public JsonObject() { _rawOrderedObject = new OrderedDictionary(StringComparer.InvariantCultureIgnoreCase); } /// /// Construct a JSON object from the OrderedDictionary obtained from reading a file on disk or parsing valid json text. /// /// Raw object parsed from disk private JsonObject(OrderedDictionary inRawObject) { _rawOrderedObject = inRawObject.CreateDeepCopy(); } /// /// Constructor /// /// public JsonObject(JsonElement element) { _rawOrderedObject = new OrderedDictionary(StringComparer.InvariantCultureIgnoreCase); foreach (JsonProperty property in element.EnumerateObject()) { _rawOrderedObject[property.Name] = ParseElement(property.Value); } } /// /// Override of the Equals method to check for equality between 2 JsonObject instances. /// /// /// Returns true if this object and obj are equal. Else returns false. public override bool Equals(object? obj) { if (obj is null || GetType() != obj.GetType()) { return false; } JsonObject jsonObject = (JsonObject) obj; if (_rawOrderedObject.Count != jsonObject._rawOrderedObject.Count) { return false; } foreach (DictionaryEntry entry in _rawOrderedObject) { if (!jsonObject._rawOrderedObject.Contains(entry.Key)) { return false; } if (!entry.Value!.Equals(jsonObject._rawOrderedObject[entry.Key!])) { return false; } } return true; } /// /// Returns the calculated hash code for a JsonObject instance. /// /// Returns the hash code for a JsonObject instance. public override int GetHashCode() { return HashCode.Combine(_rawOrderedObject); } /// /// Parse an individual element /// /// /// public static object? ParseElement(JsonElement element) { switch(element.ValueKind) { case JsonValueKind.Array: return element.EnumerateArray().Select(x => ParseElement(x)).ToArray(); case JsonValueKind.Number: return element.GetDouble(); case JsonValueKind.Object: OrderedDictionary dictionary = new OrderedDictionary(StringComparer.InvariantCultureIgnoreCase); foreach (JsonProperty property in element.EnumerateObject()) { dictionary.Add(property.Name, ParseElement(property.Value)); } return dictionary; case JsonValueKind.String: return element.GetString(); case JsonValueKind.False: return false; case JsonValueKind.True: return true; case JsonValueKind.Null: return null; default: throw new NotImplementedException(); } } private static SystemJsonNode ToJsonNode(object? obj) { // All values in the JsonObject are either parsed from a string, read from a file or set/added with the API // All values at this point must be supported and the correct types switch (obj) { case OrderedDictionary objDictionary: SystemJsonObject dictionaryJson= []; foreach (object? key in objDictionary.Keys) { dictionaryJson.Add((string) key, ToJsonNode(objDictionary[key])); } return dictionaryJson; case Array objArray: SystemJsonArray tempJsonArray = []; foreach (object? element in objArray) { tempJsonArray.Add(ToJsonNode(element)); } return tempJsonArray; default: // We support null as per the json spec return SystemJsonValue.Create(obj)!; } } /// /// Converts a JsonObject instance to a System.Text.Json.Nodes.JsonObject instance. Users can then use .NET facilities like Utf8JsonWriter to interact with this JsonObject. /// /// Returns the System.Text.Json.Nodes.JsonObject representation of this object. public SystemJsonObject ToSystemJsonObject() { SystemJsonObject returnObject = []; foreach (DictionaryEntry entry in _rawOrderedObject ) { returnObject.Add((string) entry.Key, ToJsonNode(entry.Value)); } return returnObject; } /// /// Read a JSON file from disk and construct a JsonObject from it /// /// File to read from /// New JsonObject instance public static JsonObject Read(FileReference file) { string text = FileReference.ReadAllText(file); try { return Parse(text); } catch (JsonException) { throw; } catch (Exception ex) { throw new JsonException($"Unable to parse {file}: {ex.Message}", file.FullName, null, null, ex ); } } /// /// Tries to read a JSON file from disk /// /// File to read from /// On success, receives the parsed object /// True if the file was read, false otherwise public static bool TryRead(FileReference fileName, [NotNullWhen(true)] out JsonObject? result) { if (!FileReference.Exists(fileName)) { result = null; return false; } string text = FileReference.ReadAllText(fileName); return TryParse(text, out result); } /// /// Parse a JsonObject from the given raw text string /// /// The text to parse /// New JsonObject instance public static JsonObject Parse(string text) { try { JsonDocument document = JsonDocument.Parse(text, new JsonDocumentOptions { AllowTrailingCommas = true }); return new JsonObject(document.RootElement); } catch (JsonException) { throw; } catch (Exception ex) { throw new JsonException($"Failed to parse json text '{text}'. {ex.Message}", ex); } } /// /// Try to parse a JsonObject from the given raw text string /// /// The text to parse /// On success, receives the new JsonObject /// True if the object was parsed public static bool TryParse(string text, [NotNullWhen(true)] out JsonObject? result) { try { result = Parse(text); return true; } catch (Exception) { result = null; return false; } } /// /// List of key names in this object /// public IEnumerable KeyNames => _rawOrderedObject.Keys.Cast(); /// /// Gets a string field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public string GetStringField(string fieldName) { string? stringValue; if (!TryGetStringField(fieldName, out stringValue)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return stringValue; } /// /// Tries to read a string field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetStringField(string fieldName, [NotNullWhen(true)] out string? result) { object? rawValue; if (_rawOrderedObject.TryGetValue(fieldName, out rawValue) && (rawValue is string strValue)) { result = strValue; return true; } else { result = null; return false; } } /// /// Gets a string array field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public string[] GetStringArrayField(string fieldName) { string[]? stringValues; if (!TryGetStringArrayField(fieldName, out stringValues)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return stringValues; } /// /// Tries to read a string array field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetStringArrayField(string fieldName, [NotNullWhen(true)] out string[]? result) { object? rawValue; if (_rawOrderedObject.TryGetValue(fieldName, out rawValue) && (rawValue is IEnumerable enumValue) && enumValue.All(x => x is string)) { result = enumValue.Select(x => (string)x).ToArray(); return true; } else { result = null; return false; } } /// /// Gets a boolean field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public bool GetBoolField(string fieldName) { bool boolValue; if (!TryGetBoolField(fieldName, out boolValue)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return boolValue; } /// /// Tries to read a bool field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetBoolField(string fieldName, out bool result) { object? rawValue; if (_rawOrderedObject.TryGetValue(fieldName, out rawValue) && (rawValue is bool boolValue)) { result = boolValue; return true; } else { result = false; return false; } } /// /// Gets an integer field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public int GetIntegerField(string fieldName) { int integerValue; if (!TryGetIntegerField(fieldName, out integerValue)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return integerValue; } /// /// Tries to read an integer field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetIntegerField(string fieldName, out int result) { object? rawValue; if (!_rawOrderedObject.TryGetValue(fieldName, out rawValue) || !Int32.TryParse(rawValue?.ToString(), out result)) { result = 0; return false; } return true; } /// /// Tries to read an unsigned integer field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetUnsignedIntegerField(string fieldName, out uint result) { object? rawValue; if (!_rawOrderedObject.TryGetValue(fieldName, out rawValue) || !UInt32.TryParse(rawValue?.ToString(), out result)) { result = 0; return false; } return true; } /// /// Gets a double field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public double GetDoubleField(string fieldName) { double doubleValue; if (!TryGetDoubleField(fieldName, out doubleValue)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return doubleValue; } /// /// Tries to read a double field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetDoubleField(string fieldName, out double result) { object? rawValue; if (!_rawOrderedObject.TryGetValue(fieldName, out rawValue) || !Double.TryParse(rawValue?.ToString(), out result)) { result = 0.0; return false; } return true; } /// /// Gets an enum field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public T GetEnumField(string fieldName) where T : struct { T enumValue; if (!TryGetEnumField(fieldName, out enumValue)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return enumValue; } /// /// Tries to read an enum field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetEnumField(string fieldName, out T result) where T : struct { string? stringValue; if (!TryGetStringField(fieldName, out stringValue) || !Enum.TryParse(stringValue, true, out result)) { result = default; return false; } return true; } /// /// Tries to read an enum array field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetEnumArrayField(string fieldName, [NotNullWhen(true)] out T[]? result) where T : struct { string[]? stringValues; if (!TryGetStringArrayField(fieldName, out stringValues)) { result = null; return false; } T[] enumValues = new T[stringValues.Length]; for (int idx = 0; idx < stringValues.Length; idx++) { if (!Enum.TryParse(stringValues[idx], true, out enumValues[idx])) { result = null; return false; } } result = enumValues; return true; } /// /// Gets an object field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public JsonObject GetObjectField(string fieldName) { JsonObject? result; if (!TryGetObjectField(fieldName, out result)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return result; } /// /// Tries to read an object field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetObjectField(string fieldName, [NotNullWhen(true)] out JsonObject? result) { object? rawValue; if (_rawOrderedObject.TryGetValue(fieldName, out rawValue) && (rawValue is OrderedDictionary orderedDictValue)) { result = new JsonObject(orderedDictValue); return true; } else { result = null; return false; } } /// /// Gets an object array field by the given name from the object, throwing an exception if it is not there or cannot be parsed. /// /// Name of the field to get /// The field value public JsonObject[] GetObjectArrayField(string fieldName) { JsonObject[]? result; if (!TryGetObjectArrayField(fieldName, out result)) { throw new JsonException($"Missing or invalid '{fieldName}' field"); } return result; } /// /// Tries to read an object array field by the given name from the object /// /// Name of the field to get /// On success, receives the field value /// True if the field could be read, false otherwise public bool TryGetObjectArrayField(string fieldName, [NotNullWhen(true)] out JsonObject[]? result) { object? rawValue; if (_rawOrderedObject.TryGetValue(fieldName, out rawValue) && (rawValue is IEnumerable enumValue) && enumValue.All(x => x is OrderedDictionary)) { result = enumValue.Select(x => new JsonObject((OrderedDictionary)x)).ToArray(); return true; } else { result = null; return false; } } /// /// Checks if the provided field exists in this Json object. /// /// Name of the field to check if it is contained. /// True if the field exists in the JsonObject, false otherwise public bool ContainsField(string fieldName) { return _rawOrderedObject.Contains(fieldName); } /// /// Removes a field from the given json object. /// /// Name of the field to remove public void RemoveField(string fieldName) { if (String.IsNullOrEmpty(fieldName)) { return; } if (ContainsField(fieldName)) { _rawOrderedObject.Remove(fieldName); } } /// /// Sets the integer value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The integer value to add or set. public void AddOrSetFieldValue(string fieldName, int value) { if (String.IsNullOrEmpty(fieldName)) { return; } _rawOrderedObject[fieldName] = Convert.ToDouble(value); } /// /// Sets the unsigned integer value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The unsigned integer value to add or set. public void AddOrSetFieldValue(string fieldName, uint value) { if (String.IsNullOrEmpty(fieldName)) { return; } _rawOrderedObject[fieldName] = Convert.ToDouble(value); } /// /// Sets the double value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The double value to add or set. public void AddOrSetFieldValue(string fieldName, double value) { if (String.IsNullOrEmpty(fieldName)) { return; } _rawOrderedObject[fieldName] = value; } /// /// Sets the bool value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The bool value to add or set. public void AddOrSetFieldValue(string fieldName, bool value) { if (String.IsNullOrEmpty(fieldName)) { return; } _rawOrderedObject[fieldName] = value; } /// /// Sets the string value of a field if it exists. Otherwise, adds the field and value. /// Note: If a null string is passed in as a value, it will be replaced with an empty string "" instead to ensure the field can still be added or set. /// /// The field name for the value to add or update. /// The string value to add or set. public void AddOrSetFieldValue(string fieldName, string? value) { if (String.IsNullOrEmpty(fieldName)) { return; } // We set null strings to empty strings to follow the behavior of EpicGames.Core.JsonWriter value ??= ""; _rawOrderedObject[fieldName] = value; } /// /// Sets the enum value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The enum value to add or set. public void AddOrSetFieldValue(string fieldName, T value) where T : Enum { if (String.IsNullOrEmpty(fieldName)) { return; } _rawOrderedObject[fieldName] = value.ToString(); } /// /// Sets the JsonObject value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The JsonObject value to add or set. public void AddOrSetFieldValue(string fieldName, JsonObject? value) { if (String.IsNullOrEmpty(fieldName)) { return; } _rawOrderedObject[fieldName] = value?._rawOrderedObject.CreateDeepCopy(); } /// /// Sets the string array value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The string array value to add or set. public void AddOrSetFieldValue(string fieldName, string?[]? value) { if (String.IsNullOrEmpty(fieldName)) { return; } if (value is null) { _rawOrderedObject[fieldName] = value; return; } // Contents of the strings array should never be null and should be "" instead to match behavior EpicGames.Core.JsonWriter string[] stringArray = value.Select(x => x ?? "").ToArray(); _rawOrderedObject[fieldName] = stringArray; } /// /// Sets the JsonObject array value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The JsonObject array value to add or set. public void AddOrSetFieldValue(string fieldName, JsonObject[]? value) { if (String.IsNullOrEmpty(fieldName)) { return; } if (value is null) { _rawOrderedObject[fieldName] = null; return; } List objList = []; foreach (JsonObject? obj in value) { objList.Add(obj?._rawOrderedObject.CreateDeepCopy()); } _rawOrderedObject[fieldName] = objList.ToArray(); } /// /// Sets the enum array value of a field if it exists. Otherwise, adds the field and value. /// /// The field name for the value to add or update. /// The enum array value to add or set. public void AddOrSetFieldValue(string fieldName, T[]? value) where T : Enum { if (String.IsNullOrEmpty(fieldName)) { return; } if (value is null) { _rawOrderedObject[fieldName] = null; return; } string[] stringArray = value.Select(x => x.ToString()!).ToArray(); _rawOrderedObject[fieldName] = stringArray; } /// /// Converts this Json Object to a string representation. This follows formatting of UE .uplugin files with 4 spaces for tabs and indentation enabled. /// IMPORTANT: If this JsonObject contains HTML, the returned string should NOT be used directly for HTML or a script. Read the note below. /// /// The formatted, prettified string representation of this Json Object. public string ToJsonString() { SystemJsonObject jsonObjectToWrite = ToSystemJsonObject(); JsonWriterOptions options = new JsonWriterOptions(); options.Indented = true; // IMPORTANT: Utf8JsonWriter blocks certain characters like +, &, <, >,` from being escaped in a global block list // Best way around is to use the relaxed JavaScriptEncoder.UnsafeRelaxedJsonEscaping. // However this can have security implications if this string is ever written to an HTML page or script // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/character-encoding options.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; using MemoryStream jsonMemoryStream = new MemoryStream(); using (Utf8JsonWriter writer = new Utf8JsonWriter(jsonMemoryStream, options)) { jsonObjectToWrite.WriteTo(writer); writer.Flush(); } // The Utf8JsonWriter doesn't format json the same as we want in UE, we massage the string to meet our standards string jsonString = Encoding.UTF8.GetString(jsonMemoryStream.ToArray()); string[] lines = jsonString.Split(new[] { Environment.NewLine }, StringSplitOptions.None); StringBuilder jsonStringBuilder = new StringBuilder(); foreach (string line in lines) { // Utf8JsonWriter uses 2 spaces for indents, we replace them with tabs here int numLeadingSpaces = line.TakeWhile(x => x == ' ').Count(); int numLeadingTabs = numLeadingSpaces / 2; jsonStringBuilder.Append('\t', numLeadingTabs); jsonStringBuilder.AppendLine(line.Substring(numLeadingSpaces)); } return jsonStringBuilder.ToString(); } } }