diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2018-08-01 11:07:29 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2018-08-01 11:07:29 -0400 |
commit | 60b41195778af33fd609eab66d9ae3f1d1165e8f (patch) | |
tree | 7128b906d40e94c56c34ed6058f27bc31c31a08b /src/StardewModdingAPI.Toolkit/Framework/Clients | |
parent | b9bc1a6d17cafa0a97b46ffecda432cfc2f23b51 (diff) | |
parent | 52cf953f685c65b2b6814e375ec9a5ffa03c440a (diff) | |
download | SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.tar.gz SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.tar.bz2 SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/StardewModdingAPI.Toolkit/Framework/Clients')
9 files changed, 563 insertions, 0 deletions
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs new file mode 100644 index 00000000..f3f22b93 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -0,0 +1,71 @@ +using System; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Metadata about a mod.</summary> + public class ModEntryModel + { + /********* + ** Accessors + *********/ + /// <summary>The mod's unique ID (if known).</summary> + public string ID { get; set; } + + /// <summary>The main version.</summary> + public ModEntryVersionModel Main { get; set; } + + /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary> + public ModEntryVersionModel Optional { get; set; } + + /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> + public ModEntryVersionModel Unofficial { get; set; } + + /// <summary>Optional extended data which isn't needed for update checks.</summary> + public ModExtendedMetadataModel Metadata { get; set; } + + /// <summary>The errors that occurred while fetching update data.</summary> + public string[] Errors { get; set; } = new string[0]; + + /**** + ** Backwards-compatible fields + ****/ + /// <summary>The mod's latest version number.</summary> + [Obsolete("Use " + nameof(ModEntryModel.Main))] + [JsonProperty] + internal string Version { get; private set; } + + /// <summary>The mod's web URL.</summary> + [Obsolete("Use " + nameof(ModEntryModel.Main))] + [JsonProperty] + internal string Url { get; private set; } + + /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary> + [Obsolete("Use " + nameof(ModEntryModel.Optional))] + [JsonProperty] + internal string PreviewVersion { get; private set; } + + /// <summary>The web URL to the mod's latest optional release, if newer than <see cref="Version"/>.</summary> + [Obsolete("Use " + nameof(ModEntryModel.Optional))] + [JsonProperty] + internal string PreviewUrl { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Set backwards-compatible fields.</summary> + /// <param name="version">The requested API version.</param> + public void SetBackwardsCompatibility(ISemanticVersion version) + { + if (version.IsOlderThan("2.6-beta.19")) + { + this.Version = this.Main?.Version?.ToString(); + this.Url = this.Main?.Url; + + this.PreviewVersion = this.Optional?.Version?.ToString(); + this.PreviewUrl = this.Optional?.Url; + } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs new file mode 100644 index 00000000..dadb8c10 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs @@ -0,0 +1,31 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Metadata about a version.</summary> + public class ModEntryVersionModel + { + /********* + ** Accessors + *********/ + /// <summary>The version number.</summary> + public ISemanticVersion Version { get; set; } + + /// <summary>The mod page URL.</summary> + public string Url { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModEntryVersionModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The version number.</param> + /// <param name="url">The mod page URL.</param> + public ModEntryVersionModel(ISemanticVersion version, string url) + { + this.Version = version; + this.Url = url; + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs new file mode 100644 index 00000000..21376b36 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Toolkit.Framework.ModData; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Extended metadata about a mod.</summary> + public class ModExtendedMetadataModel + { + /********* + ** Accessors + *********/ + /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> + public string[] ID { get; set; } = new string[0]; + + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The mod ID on Nexus.</summary> + public int? NexusID { get; set; } + + /// <summary>The mod ID in the Chucklefish mod repo.</summary> + public int? ChucklefishID { get; set; } + + /// <summary>The GitHub repository in the form 'owner/repo'.</summary> + public string GitHubRepo { get; set; } + + /// <summary>The URL to a non-GitHub source repo.</summary> + public string CustomSourceUrl { get; set; } + + /// <summary>The custom mod page URL (if applicable).</summary> + public string CustomUrl { get; set; } + + /// <summary>The compatibility status.</summary> + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? CompatibilityStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary> + public string CompatibilitySummary { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModExtendedMetadataModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="wiki">The mod metadata from the wiki (if available).</param> + /// <param name="db">The mod metadata from SMAPI's internal DB (if available).</param> + public ModExtendedMetadataModel(WikiCompatibilityEntry wiki, ModDataRecord db) + { + // wiki data + if (wiki != null) + { + this.ID = wiki.ID; + this.Name = wiki.Name; + this.NexusID = wiki.NexusID; + this.ChucklefishID = wiki.ChucklefishID; + this.GitHubRepo = wiki.GitHubRepo; + this.CustomSourceUrl = wiki.CustomSourceUrl; + this.CustomUrl = wiki.CustomUrl; + this.CompatibilityStatus = wiki.Status; + this.CompatibilitySummary = wiki.Summary; + } + + // internal DB data + if (db != null) + { + this.ID = this.ID.Union(db.FormerIDs).ToArray(); + this.Name = this.Name ?? db.DisplayName; + } + } + + /// <summary>Get update keys based on the metadata.</summary> + public IEnumerable<string> GetUpdateKeys() + { + if (this.NexusID.HasValue) + yield return $"Nexus:{this.NexusID}"; + if (this.ChucklefishID.HasValue) + yield return $"Chucklefish:{this.ChucklefishID}"; + if (this.GitHubRepo != null) + yield return $"GitHub:{this.GitHubRepo}"; + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs new file mode 100644 index 00000000..df0d8457 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Specifies mods whose update-check info to fetch.</summary> + public class ModSearchModel + { + /********* + ** Accessors + *********/ + /// <summary>The namespaced mod keys to search.</summary> + [Obsolete] + public string[] ModKeys { get; set; } + + /// <summary>The mods for which to find data.</summary> + public ModSearchEntryModel[] Mods { get; set; } + + /// <summary>Whether to include extended metadata for each mod.</summary> + public bool IncludeExtendedMetadata { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public ModSearchModel() + { + // needed for JSON deserialising + } + + /// <summary>Construct an instance.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> + public ModSearchModel(ModSearchEntryModel[] mods, bool includeExtendedMetadata) + { + this.Mods = mods.ToArray(); + this.IncludeExtendedMetadata = includeExtendedMetadata; + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs new file mode 100644 index 00000000..bca47647 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs @@ -0,0 +1,34 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Specifies the identifiers for a mod to match.</summary> + public class ModSearchEntryModel + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID.</summary> + public string ID { get; set; } + + /// <summary>The namespaced mod update keys (if available).</summary> + public string[] UpdateKeys { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public ModSearchEntryModel() + { + // needed for JSON deserialising + } + + /// <summary>Construct an instance.</summary> + /// <param name="id">The unique mod ID.</param> + /// <param name="updateKeys">The namespaced mod update keys (if available).</param> + public ModSearchEntryModel(string id, string[] updateKeys) + { + this.ID = id; + this.UpdateKeys = updateKeys ?? new string[0]; + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs new file mode 100644 index 00000000..0ecd9664 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Provides methods for interacting with the SMAPI web API.</summary> + public class WebApiClient + { + /********* + ** Properties + *********/ + /// <summary>The base URL for the web API.</summary> + private readonly Uri BaseUrl; + + /// <summary>The API version number.</summary> + private readonly ISemanticVersion Version; + + /// <summary>The JSON serializer settings to use.</summary> + private readonly JsonSerializerSettings JsonSettings = new JsonHelper().JsonSettings; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="baseUrl">The base URL for the web API.</param> + /// <param name="version">The web API version.</param> + public WebApiClient(string baseUrl, ISemanticVersion version) + { + this.BaseUrl = new Uri(baseUrl); + this.Version = version; + } + + /// <summary>Get metadata about a set of mods from the web API.</summary> + /// <param name="mods">The mod keys for which to fetch the latest version.</param> + /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> + public IDictionary<string, ModEntryModel> GetModInfo(ModSearchEntryModel[] mods, bool includeExtendedMetadata = false) + { + return this.Post<ModSearchModel, ModEntryModel[]>( + $"v{this.Version}/mods", + new ModSearchModel(mods, includeExtendedMetadata) + ).ToDictionary(p => p.ID); + } + + + /********* + ** Private methods + *********/ + /// <summary>Fetch the response from the backend API.</summary> + /// <typeparam name="TBody">The body content type.</typeparam> + /// <typeparam name="TResult">The expected response type.</typeparam> + /// <param name="url">The request URL, optionally excluding the base URL.</param> + /// <param name="content">The body content to post.</param> + private TResult Post<TBody, TResult>(string url, TBody content) + { + // note: avoid HttpClient for Mac compatibility + using (WebClient client = new WebClient()) + { + Uri fullUrl = new Uri(this.BaseUrl, url); + string data = JsonConvert.SerializeObject(content); + + client.Headers["Content-Type"] = "application/json"; + client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; + string response = client.UploadString(fullUrl, data); + return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); + } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs new file mode 100644 index 00000000..d0da42df --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>An HTTP client for fetching mod metadata from the wiki compatibility list.</summary> + public class WikiCompatibilityClient : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="userAgent">The user agent for the wiki API.</param> + /// <param name="baseUrl">The base URL for the wiki API.</param> + public WikiCompatibilityClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// <summary>Fetch mod compatibility entries.</summary> + public async Task<WikiCompatibilityEntry[]> FetchAsync() + { + // fetch HTML + ResponseModel response = await this.Client + .GetAsync("") + .WithArguments(new + { + action = "parse", + page = "Modding:SMAPI_compatibility", + format = "json" + }) + .As<ResponseModel>(); + string html = response.Parse.Text["*"]; + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // find mod entries + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + + // parse + return this.ParseEntries(modNodes).ToArray(); + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Parse valid mod compatibility entries.</summary> + /// <param name="nodes">The HTML compatibility entries.</param> + private IEnumerable<WikiCompatibilityEntry> ParseEntries(IEnumerable<HtmlNode> nodes) + { + foreach (HtmlNode node in nodes) + { + // parse status + WikiCompatibilityStatus status; + { + string rawStatus = node.GetAttributeValue("data-status", null); + if (rawStatus == null) + continue; // not a mod node? + if (!Enum.TryParse(rawStatus, true, out status)) + throw new InvalidOperationException($"Unknown status '{rawStatus}' when parsing compatibility list."); + } + + // parse unofficial version + ISemanticVersion unofficialVersion = null; + { + string rawUnofficialVersion = node.GetAttributeValue("data-unofficial-version", null); + SemanticVersion.TryParse(rawUnofficialVersion, out unofficialVersion); + } + + // parse other fields + string name = node.Descendants("td").FirstOrDefault()?.InnerText?.Trim(); + string summary = node.Descendants("td").FirstOrDefault(p => p.GetAttributeValue("class", null) == "summary")?.InnerText.Trim(); + string[] ids = this.GetAttribute(node, "data-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0]; + int? nexusID = this.GetNullableIntAttribute(node, "data-nexus-id"); + int? chucklefishID = this.GetNullableIntAttribute(node, "data-chucklefish-id"); + string githubRepo = this.GetAttribute(node, "data-github"); + string customSourceUrl = this.GetAttribute(node, "data-custom-source"); + string customUrl = this.GetAttribute(node, "data-custom-url"); + + // yield model + yield return new WikiCompatibilityEntry + { + ID = ids, + Name = name, + Status = status, + NexusID = nexusID, + ChucklefishID = chucklefishID, + GitHubRepo = githubRepo, + CustomSourceUrl = customSourceUrl, + CustomUrl = customUrl, + UnofficialVersion = unofficialVersion, + Summary = summary + }; + } + } + + /// <summary>Get a nullable integer attribute value.</summary> + /// <param name="node">The HTML node.</param> + /// <param name="attributeName">The attribute name.</param> + private int? GetNullableIntAttribute(HtmlNode node, string attributeName) + { + string raw = this.GetAttribute(node, attributeName); + if (raw != null && int.TryParse(raw, out int value)) + return value; + return null; + } + + /// <summary>Get a strings attribute value.</summary> + /// <param name="node">The HTML node.</param> + /// <param name="attributeName">The attribute name.</param> + private string GetAttribute(HtmlNode node, string attributeName) + { + string raw = node.GetAttributeValue(attributeName, null); + if (raw != null) + raw = HtmlEntity.DeEntitize(raw); + return raw; + } + + /// <summary>The response model for the MediaWiki parse API.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseModel + { + /// <summary>The parse API results.</summary> + public ResponseParseModel Parse { get; set; } + } + + /// <summary>The inner response model for the MediaWiki parse API.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseParseModel + { + /// <summary>The parsed text.</summary> + public IDictionary<string, string> Text { get; set; } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs new file mode 100644 index 00000000..8bc66e20 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs @@ -0,0 +1,36 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>An entry in the mod compatibility list.</summary> + public class WikiCompatibilityEntry + { + /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> + public string[] ID { get; set; } + + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The mod ID on Nexus.</summary> + public int? NexusID { get; set; } + + /// <summary>The mod ID in the Chucklefish mod repo.</summary> + public int? ChucklefishID { get; set; } + + /// <summary>The GitHub repository in the form 'owner/repo'.</summary> + public string GitHubRepo { get; set; } + + /// <summary>The URL to a non-GitHub source repo.</summary> + public string CustomSourceUrl { get; set; } + + /// <summary>The custom mod page URL (if applicable).</summary> + public string CustomUrl { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public ISemanticVersion UnofficialVersion { get; set; } + + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus Status { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary> + public string Summary { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs new file mode 100644 index 00000000..a1d2dfae --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>The compatibility status for a mod.</summary> + public enum WikiCompatibilityStatus + { + /// <summary>The mod is compatible.</summary> + Ok = 0, + + /// <summary>The mod is compatible if you use an optional official download.</summary> + Optional = 1, + + /// <summary>The mod is compatible if you use an unofficial update.</summary> + Unofficial = 2, + + /// <summary>The mod isn't compatible, but the player can fix it or there's a good alternative.</summary> + Workaround = 3, + + /// <summary>The mod isn't compatible.</summary> + Broken = 4, + + /// <summary>The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely.</summary> + Abandoned = 5, + + /// <summary>The mod is no longer needed and should be removed.</summary> + Obsolete = 6 + } +} |