summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI.Toolkit/Serialisation
diff options
context:
space:
mode:
Diffstat (limited to 'src/StardewModdingAPI.Toolkit/Serialisation')
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs50
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs60
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs88
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs76
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs22
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs21
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs123
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs74
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs15
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs35
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs17
11 files changed, 581 insertions, 0 deletions
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs
new file mode 100644
index 00000000..232c22a7
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs
@@ -0,0 +1,50 @@
+using System;
+using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
+{
+ /// <summary>Handles deserialisation of <see cref="ManifestContentPackFor"/> arrays.</summary>
+ public class ManifestContentPackForConverter : JsonConverter
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether this converter can write JSON.</summary>
+ public override bool CanWrite => false;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="objectType">The object type.</param>
+ public override bool CanConvert(Type objectType)
+ {
+ return objectType == typeof(ManifestContentPackFor[]);
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Read the JSON representation of the object.</summary>
+ /// <param name="reader">The JSON reader.</param>
+ /// <param name="objectType">The object type.</param>
+ /// <param name="existingValue">The object being read.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ return serializer.Deserialize<ManifestContentPackFor>(reader);
+ }
+
+ /// <summary>Writes the JSON representation of the object.</summary>
+ /// <param name="writer">The JSON writer.</param>
+ /// <param name="value">The value.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ {
+ throw new InvalidOperationException("This converter does not write JSON.");
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs
new file mode 100644
index 00000000..0a304ee3
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
+{
+ /// <summary>Handles deserialisation of <see cref="ManifestDependency"/> arrays.</summary>
+ internal class ManifestDependencyArrayConverter : JsonConverter
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether this converter can write JSON.</summary>
+ public override bool CanWrite => false;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="objectType">The object type.</param>
+ public override bool CanConvert(Type objectType)
+ {
+ return objectType == typeof(ManifestDependency[]);
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Read the JSON representation of the object.</summary>
+ /// <param name="reader">The JSON reader.</param>
+ /// <param name="objectType">The object type.</param>
+ /// <param name="existingValue">The object being read.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ List<ManifestDependency> result = new List<ManifestDependency>();
+ foreach (JObject obj in JArray.Load(reader).Children<JObject>())
+ {
+ string uniqueID = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.UniqueID));
+ string minVersion = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.MinimumVersion));
+ bool required = obj.ValueIgnoreCase<bool?>(nameof(ManifestDependency.IsRequired)) ?? true;
+ result.Add(new ManifestDependency(uniqueID, minVersion, required));
+ }
+ return result.ToArray();
+ }
+
+ /// <summary>Writes the JSON representation of the object.</summary>
+ /// <param name="writer">The JSON writer.</param>
+ /// <param name="value">The value.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ {
+ throw new InvalidOperationException("This converter does not write JSON.");
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
new file mode 100644
index 00000000..9b2f5e7d
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
@@ -0,0 +1,88 @@
+using System;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
+{
+ /// <summary>Handles deserialisation of <see cref="ISemanticVersion"/>.</summary>
+ internal class SemanticVersionConverter : JsonConverter
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Get whether this converter can read JSON.</summary>
+ public override bool CanRead => true;
+
+ /// <summary>Get whether this converter can write JSON.</summary>
+ public override bool CanWrite => true;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="objectType">The object type.</param>
+ public override bool CanConvert(Type objectType)
+ {
+ return typeof(ISemanticVersion).IsAssignableFrom(objectType);
+ }
+
+ /// <summary>Reads the JSON representation of the object.</summary>
+ /// <param name="reader">The JSON reader.</param>
+ /// <param name="objectType">The object type.</param>
+ /// <param name="existingValue">The object being read.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ string path = reader.Path;
+ switch (reader.TokenType)
+ {
+ case JsonToken.StartObject:
+ return this.ReadObject(JObject.Load(reader));
+ case JsonToken.String:
+ return this.ReadString(JToken.Load(reader).Value<string>(), path);
+ default:
+ throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path}).");
+ }
+ }
+
+ /// <summary>Writes the JSON representation of the object.</summary>
+ /// <param name="writer">The JSON writer.</param>
+ /// <param name="value">The value.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ {
+ writer.WriteValue(value?.ToString());
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Read a JSON object.</summary>
+ /// <param name="obj">The JSON object to read.</param>
+ private ISemanticVersion ReadObject(JObject obj)
+ {
+ int major = obj.ValueIgnoreCase<int>("MajorVersion");
+ int minor = obj.ValueIgnoreCase<int>("MinorVersion");
+ int patch = obj.ValueIgnoreCase<int>("PatchVersion");
+ string build = obj.ValueIgnoreCase<string>("Build");
+ if (build == "0")
+ build = null; // '0' from incorrect examples in old SMAPI documentation
+
+ return new SemanticVersion(major, minor, patch, build);
+ }
+
+ /// <summary>Read a JSON string.</summary>
+ /// <param name="str">The JSON string value.</param>
+ /// <param name="path">The path to the current JSON node.</param>
+ private ISemanticVersion ReadString(string str, string path)
+ {
+ if (string.IsNullOrWhiteSpace(str))
+ return null;
+ if (!SemanticVersion.TryParse(str, out ISemanticVersion version))
+ throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path}).");
+ return (SemanticVersion)version;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs
new file mode 100644
index 00000000..5e0b0f4a
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs
@@ -0,0 +1,76 @@
+using System;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
+{
+ /// <summary>The base implementation for simplified converters which deserialise <typeparamref name="T"/> without overriding serialisation.</summary>
+ /// <typeparam name="T">The type to deserialise.</typeparam>
+ internal abstract class SimpleReadOnlyConverter<T> : JsonConverter
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether this converter can write JSON.</summary>
+ public override bool CanWrite => false;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="objectType">The object type.</param>
+ public override bool CanConvert(Type objectType)
+ {
+ return objectType == typeof(T);
+ }
+
+ /// <summary>Writes the JSON representation of the object.</summary>
+ /// <param name="writer">The JSON writer.</param>
+ /// <param name="value">The value.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ {
+ throw new InvalidOperationException("This converter does not write JSON.");
+ }
+
+ /// <summary>Reads the JSON representation of the object.</summary>
+ /// <param name="reader">The JSON reader.</param>
+ /// <param name="objectType">The object type.</param>
+ /// <param name="existingValue">The object being read.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ string path = reader.Path;
+ switch (reader.TokenType)
+ {
+ case JsonToken.StartObject:
+ return this.ReadObject(JObject.Load(reader), path);
+ case JsonToken.String:
+ return this.ReadString(JToken.Load(reader).Value<string>(), path);
+ default:
+ throw new SParseException($"Can't parse {typeof(T).Name} from {reader.TokenType} node (path: {reader.Path}).");
+ }
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Read a JSON object.</summary>
+ /// <param name="obj">The JSON object to read.</param>
+ /// <param name="path">The path to the current JSON node.</param>
+ protected virtual T ReadObject(JObject obj, string path)
+ {
+ throw new SParseException($"Can't parse {typeof(T).Name} from object node (path: {path}).");
+ }
+
+ /// <summary>Read a JSON string.</summary>
+ /// <param name="str">The JSON string value.</param>
+ /// <param name="path">The path to the current JSON node.</param>
+ protected virtual T ReadString(string str, string path)
+ {
+ throw new SParseException($"Can't parse {typeof(T).Name} from string node (path: {path}).");
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs
new file mode 100644
index 00000000..13e6e3a1
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs
@@ -0,0 +1,22 @@
+using System;
+using Newtonsoft.Json.Converters;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
+{
+ /// <summary>A variant of <see cref="StringEnumConverter"/> which only converts a specified enum.</summary>
+ /// <typeparam name="T">The enum type.</typeparam>
+ internal class StringEnumConverter<T> : StringEnumConverter
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="type">The object type.</param>
+ public override bool CanConvert(Type type)
+ {
+ return
+ base.CanConvert(type)
+ && (Nullable.GetUnderlyingType(type) ?? type) == typeof(T);
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs b/src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs
new file mode 100644
index 00000000..12b2c933
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs
@@ -0,0 +1,21 @@
+using System;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Toolkit.Serialisation
+{
+ /// <summary>Provides extension methods for parsing JSON.</summary>
+ public static class JsonExtensions
+ {
+ /// <summary>Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="obj">The JSON object to search.</param>
+ /// <param name="fieldName">The field name.</param>
+ public static T ValueIgnoreCase<T>(this JObject obj, string fieldName)
+ {
+ JToken token = obj.GetValue(fieldName, StringComparison.InvariantCultureIgnoreCase);
+ return token != null
+ ? token.Value<T>()
+ : default(T);
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
new file mode 100644
index 00000000..00f334ad
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
+
+namespace StardewModdingAPI.Toolkit.Serialisation
+{
+ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
+ public class JsonHelper
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The JSON settings to use when serialising and deserialising files.</summary>
+ public JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings
+ {
+ Formatting = Formatting.Indented,
+ ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded
+ Converters = new List<JsonConverter> { new SemanticVersionConverter() }
+ };
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Read a JSON file.</summary>
+ /// <typeparam name="TModel">The model type.</typeparam>
+ /// <param name="fullPath">The absolete file path.</param>
+ /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns>
+ /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception>
+ public TModel ReadJsonFile<TModel>(string fullPath)
+ where TModel : class
+ {
+ // 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 || ex is FileNotFoundException)
+ {
+ return null;
+ }
+
+ // deserialise model
+ try
+ {
+ return this.Deserialise<TModel>(json);
+ }
+ 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);
+ }
+ }
+
+ /// <summary>Save to a JSON file.</summary>
+ /// <typeparam name="TModel">The model type.</typeparam>
+ /// <param name="fullPath">The absolete file path.</param>
+ /// <param name="model">The model to save.</param>
+ /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception>
+ public void WriteJsonFile<TModel>(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 = JsonConvert.SerializeObject(model, this.JsonSettings);
+ File.WriteAllText(fullPath, json);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Deserialize JSON text if possible.</summary>
+ /// <typeparam name="TModel">The model type.</typeparam>
+ /// <param name="json">The raw JSON text.</param>
+ private TModel Deserialise<TModel>(string json)
+ {
+ try
+ {
+ return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings);
+ }
+ catch (JsonReaderException)
+ {
+ // try replacing curly quotes
+ if (json.Contains("“") || json.Contains("”"))
+ {
+ try
+ {
+ return JsonConvert.DeserializeObject<TModel>(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings);
+ }
+ catch { /* rethrow original error */ }
+ }
+
+ throw;
+ }
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs
new file mode 100644
index 00000000..6cb9496b
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Models
+{
+ /// <summary>A manifest which describes a mod for SMAPI.</summary>
+ public class Manifest : IManifest
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>A brief description of the mod.</summary>
+ public string Description { get; set; }
+
+ /// <summary>The mod author's name.</summary>
+ public string Author { get; set; }
+
+ /// <summary>The mod version.</summary>
+ public ISemanticVersion Version { get; set; }
+
+ /// <summary>The minimum SMAPI version required by this mod, if any.</summary>
+ public ISemanticVersion MinimumApiVersion { get; set; }
+
+ /// <summary>The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary>
+ public string EntryDll { get; set; }
+
+ /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="Manifest.EntryDll"/>.</summary>
+ [JsonConverter(typeof(ManifestContentPackForConverter))]
+ public IManifestContentPackFor ContentPackFor { get; set; }
+
+ /// <summary>The other mods that must be loaded before this mod.</summary>
+ [JsonConverter(typeof(ManifestDependencyArrayConverter))]
+ public IManifestDependency[] Dependencies { get; set; }
+
+ /// <summary>The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</summary>
+ public string[] UpdateKeys { get; set; }
+
+ /// <summary>The unique mod ID.</summary>
+ public string UniqueID { get; set; }
+
+ /// <summary>Any manifest fields which didn't match a valid field.</summary>
+ [JsonExtensionData]
+ public IDictionary<string, object> ExtraFields { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public Manifest() { }
+
+ /// <summary>Construct an instance for a transitional content pack.</summary>
+ /// <param name="uniqueID">The unique mod ID.</param>
+ /// <param name="name">The mod name.</param>
+ /// <param name="author">The mod author's name.</param>
+ /// <param name="description">A brief description of the mod.</param>
+ /// <param name="version">The mod version.</param>
+ /// <param name="contentPackFor">The modID which will read this as a content pack.</param>
+ public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string contentPackFor = null)
+ {
+ this.Name = name;
+ this.Author = author;
+ this.Description = description;
+ this.Version = version;
+ this.UniqueID = uniqueID;
+ this.UpdateKeys = new string[0];
+ this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor };
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs
new file mode 100644
index 00000000..d0e42216
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Toolkit.Serialisation.Models
+{
+ /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary>
+ public class ManifestContentPackFor : IManifestContentPackFor
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique ID of the mod which can read this content pack.</summary>
+ public string UniqueID { get; set; }
+
+ /// <summary>The minimum required version (if any).</summary>
+ public ISemanticVersion MinimumVersion { get; set; }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs
new file mode 100644
index 00000000..8db58d5d
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs
@@ -0,0 +1,35 @@
+namespace StardewModdingAPI.Toolkit.Serialisation.Models
+{
+ /// <summary>A mod dependency listed in a mod manifest.</summary>
+ public class ManifestDependency : IManifestDependency
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique mod ID to require.</summary>
+ public string UniqueID { get; set; }
+
+ /// <summary>The minimum required version (if any).</summary>
+ public ISemanticVersion MinimumVersion { get; set; }
+
+ /// <summary>Whether the dependency must be installed to use the mod.</summary>
+ public bool IsRequired { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="uniqueID">The unique mod ID to require.</param>
+ /// <param name="minimumVersion">The minimum required version (if any).</param>
+ /// <param name="required">Whether the dependency must be installed to use the mod.</param>
+ public ManifestDependency(string uniqueID, string minimumVersion, bool required = true)
+ {
+ this.UniqueID = uniqueID;
+ this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion)
+ ? new SemanticVersion(minimumVersion)
+ : null;
+ this.IsRequired = required;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs b/src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs
new file mode 100644
index 00000000..61a7b305
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace StardewModdingAPI.Toolkit.Serialisation
+{
+ /// <summary>A format exception which provides a user-facing error message.</summary>
+ internal class SParseException : FormatException
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The error message.</param>
+ /// <param name="ex">The underlying exception, if any.</param>
+ public SParseException(string message, Exception ex = null)
+ : base(message, ex) { }
+ }
+}