using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using StardewModdingAPI.Toolkit.Serialization.Converters; namespace StardewModdingAPI.Toolkit.Serialization { /// Encapsulates SMAPI's JSON file parsing. public class JsonHelper { /********* ** Accessors *********/ /// The JSON settings to use when serializing and deserializing files. public JsonSerializerSettings JsonSettings { get; } = new() { Formatting = Formatting.Indented, ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection values are duplicated each time the config is loaded Converters = new List { new SemanticVersionConverter(), new StringEnumConverter() } }; /********* ** Public methods *********/ /// Read a JSON file. /// The model type. /// The absolute file path. /// The parsed content model. /// Returns false if the file doesn't exist, else true. /// The given is empty or invalid. /// The file contains invalid JSON. public bool ReadJsonFileIfExists(string fullPath, #if NET5_0_OR_GREATER [NotNullWhen(true)] #endif out TModel? result ) { // validate if (string.IsNullOrWhiteSpace(fullPath)) throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); // read file string json; try { json = File.ReadAllText(fullPath); } catch (Exception ex) when (ex is DirectoryNotFoundException or FileNotFoundException) { result = default; return false; } // deserialize model try { result = this.Deserialize(json); return result != null; } catch (Exception ex) { string error = $"Can't parse JSON file at {fullPath}."; if (ex is JsonReaderException) { error += " This doesn't seem to be valid JSON."; if (json.Contains("“") || json.Contains("”")) error += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; } error += $"\nTechnical details: {ex.Message}"; throw new JsonReaderException(error); } } /// Save to a JSON file. /// The model type. /// The absolute file path. /// The model to save. /// The given path is empty or invalid. public void WriteJsonFile(string fullPath, TModel model) where TModel : class { // validate if (string.IsNullOrWhiteSpace(fullPath)) throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); // create directory if needed string dir = Path.GetDirectoryName(fullPath)!; if (dir == null) throw new ArgumentException("The file path is invalid.", nameof(fullPath)); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); // write file string json = this.Serialize(model); File.WriteAllText(fullPath, json); } /// Deserialize JSON text if possible. /// The model type. /// The raw JSON text. public TModel? Deserialize(string json) { try { return JsonConvert.DeserializeObject(json, this.JsonSettings); } catch (JsonReaderException) { // try replacing curly quotes if (json.Contains("“") || json.Contains("”")) { try { return JsonConvert.DeserializeObject(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings) ?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON."); } catch { /* rethrow original error */ } } throw; } } /// Serialize a model to JSON text. /// The model type. /// The model to serialize. /// The formatting to apply. public string Serialize(TModel model, Formatting formatting = Formatting.Indented) { return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); } /// Get a low-level JSON serializer matching the . public JsonSerializer GetSerializer() { return JsonSerializer.CreateDefault(this.JsonSettings); } } }