diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 7fc1f30bc..8871d9bdf 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs index 208cd6567..0552a23e1 100644 --- a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs +++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs @@ -4,6 +4,8 @@ using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Schema; +using Newtonsoft.Json.Schema.Generation; using StardewModdingAPI.Toolkit.Serialization.Converters; namespace StardewModdingAPI.Toolkit.Serialization @@ -17,6 +19,13 @@ public class JsonHelper /// The JSON settings to use when serializing and deserializing files. public JsonSerializerSettings JsonSettings { get; } = JsonHelper.CreateDefaultSettings(); + private readonly JSchemaGenerator _schemaGenerator = new(); + + private readonly JSchemaWriterSettings _schemaWriterSettings = new() + { + Version = SchemaVersion.Draft2019_09, + ReferenceHandling = JSchemaWriterReferenceHandling.Never + }; /********* ** Public methods @@ -111,6 +120,30 @@ public void WriteJsonFile(string fullPath, TModel model) File.WriteAllText(fullPath, json); } + /// Generate a schema and save to a JSON file. + /// The model type. + /// The absolute file path. + /// The given path is empty or invalid. + public void WriteJsonSchemaFile(string fullPath) + 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); + + JSchema schema = this._schemaGenerator.Generate(typeof(TModel)); + string output = schema.ToString(this._schemaWriterSettings); + + File.WriteAllText(fullPath, output); + } + /// Deserialize JSON text if possible. /// The model type. /// The raw JSON text. diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 2eaa940a7..dd8cd373e 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -67,9 +67,19 @@ public void WriteJsonFile(string path, TModel? data) File.Delete(path); } + /// + public void WriteJsonSchemaFile(string path, TModel data) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); + + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path)); + this.JsonHelper.WriteJsonSchemaFile(path); + } + /**** - ** Save file - ****/ + ** Save file + ****/ /// public TModel? ReadSaveData(string key) where TModel : class diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index d1cf357e9..13efbd683 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -8,6 +8,12 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Provides simplified APIs for writing mods. internal class ModHelper : BaseHelper, IModHelper, IDisposable { + /********* + ** Fields + *********/ + /// Whether to generate config.schema.json files. + private readonly bool _generateConfigSchemas; + /********* ** Accessors *********/ @@ -65,9 +71,10 @@ internal class ModHelper : BaseHelper, IModHelper, IDisposable /// An API for accessing private game code. /// Provides multiplayer utilities. /// An API for reading translations stored in the mod's i18n folder. + /// Whether to generate config.schema.json files. /// An argument is null or empty. /// The path does not exist on disk. - public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) + public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, bool generateConfigSchemas) : base(mod) { // validate directory @@ -89,6 +96,7 @@ public ModHelper(IModMetadata mod, string modDirectory, Func curren this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); this.Events = events; + this._generateConfigSchemas = generateConfigSchemas; } /**** @@ -100,6 +108,12 @@ public TConfig ReadConfig() { TConfig config = this.Data.ReadJsonFile("config.json") ?? new TConfig(); this.WriteConfig(config); // create file or fill in missing fields + + if (this._generateConfigSchemas) + { + this.WriteConfigSchema(config); + } + return config; } @@ -110,6 +124,12 @@ public void WriteConfig(TConfig config) this.Data.WriteJsonFile("config.json", config); } + private void WriteConfigSchema(TConfig config) + where TConfig : class, new() + { + this.Data.WriteJsonSchemaFile("config.schema.json", config); + } + /**** ** Disposal ****/ diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 9548781fe..cc351c3ff 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1953,7 +1953,7 @@ IContentPack[] GetContentPacks() IModRegistry modRegistryHelper = new ModRegistryHelper(mod, this.ModRegistry, proxyFactory, monitor); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(mod, this.Multiplayer); - modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, this.Settings.GenerateConfigSchemas); } // init mod diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs index 7ddf851ef..23d026e31 100644 --- a/src/SMAPI/IDataHelper.cs +++ b/src/SMAPI/IDataHelper.cs @@ -27,6 +27,14 @@ public interface IDataHelper void WriteJsonFile(string path, TModel? data) where TModel : class; + /// Save the schema of the data to a JSON file in the mod's folder. + /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. + /// The file path relative to the mod folder. + /// The arbitrary data to save. + /// The is not relative or contains directory climbing (../). + void WriteJsonSchemaFile(string path, TModel data) + where TModel : class; + /**** ** Save file ****/