From 6d4eed56b1d80d02db773b0cd2f372baec6b2d1b Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Thu, 22 Sep 2022 23:03:26 -0400 Subject: refactor UpdateKey parsing, move responsibility for subkey matching UpdateKey parsing now allows multiple : and @ inside the update key, splitting on the first occurence of each Subkey matching is moved into IModDownload / GenericModDownload, in preparation for some Mod Sites using something less error-prone than substring matching. --- .../Framework/UpdateData/UpdateKey.cs | 45 +++++++++++++--------- 1 file changed, 27 insertions(+), 18 deletions(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 960caf96..7c2cc73c 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -54,35 +54,44 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData public UpdateKey(ModSiteKey site, string? id, string? subkey) : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } + /// + /// Split a string into two at a delimiter. If the delimiter does not appear in the string then the second + /// value of the returned tuple is null. Both returned strings are trimmed of whitespace. + /// + /// The string to split. + /// The character on which to split. + /// + /// If true then the second string returned will include the delimiter character + /// (provided that the string is not null) + /// + /// + /// A pair containing the string consisting of all characters in before the first + /// occurrence of , and a string consisting of all characters in + /// after the first occurrence of or null if the delimiter does not + /// exist in s. Both strings are trimmed of whitespace. + /// + private static (string, string?) Bifurcate(string s, char delimiter, bool keepDelimiter = false) { + int pos = s.IndexOf(delimiter); + if (pos < 0) + return (s.Trim(), null); + return (s.Substring(0, pos).Trim(), s.Substring(pos + (keepDelimiter ? 0 : 1)).Trim()); + } + /// Parse a raw update key. /// The raw update key to parse. public static UpdateKey Parse(string? raw) { + if (raw is null) + return new UpdateKey(raw, ModSiteKey.Unknown, null, null); // extract site + ID - string? rawSite; - string? id; - { - string[]? parts = raw?.Trim().Split(':'); - if (parts?.Length != 2) - return new UpdateKey(raw, ModSiteKey.Unknown, null, null); - - rawSite = parts[0].Trim(); - id = parts[1].Trim(); - } + (string rawSite, string? id) = Bifurcate(raw, ':'); if (string.IsNullOrWhiteSpace(id)) id = null; // extract subkey string? subkey = null; if (id != null) - { - string[] parts = id.Split('@'); - if (parts.Length == 2) - { - id = parts[0].Trim(); - subkey = $"@{parts[1]}".Trim(); - } - } + (id, subkey) = Bifurcate(id, '@', true); // parse if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) -- cgit From 83aec980b3ee739fb4bc251217556b3ae44f741b Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Sat, 1 Oct 2022 21:09:39 -0400 Subject: Add UpdateManifest site type. Adds the UpdateManifest site key and associated client. This required some additional features in the existing update machinery. Each "version" can now (optionally) have its own download URL. Mod Page objects can now specify that subkey matching (for that page) should be "strict". A strict subkey match does not fall back to matching with no subkey if a subkey was provided but produced no versions. It also strips the leading '@' from the subkey. IModDownload objects are now responsible for deciding whether a subkey matches or not. The default behavior is unchanged, but this allows different mod sites to have different rules for subkey matching (which the UpdateManifest mod site uses to force exact matches). --- .../Framework/UpdateData/ModSiteKey.cs | 5 +- src/SMAPI.Web/Controllers/ModsApiController.cs | 14 +++-- .../Framework/Clients/GenericModDownload.cs | 15 +++-- src/SMAPI.Web/Framework/Clients/GenericModPage.cs | 16 ++++++ .../UpdateManifest/IUpdateManifestClient.cs | 9 +++ .../UpdateManifest/TextAsJsonMediaTypeFormatter.cs | 15 +++++ .../Clients/UpdateManifest/UpdateManifestClient.cs | 55 +++++++++++++++++++ .../UpdateManifest/UpdateManifestModDownload.cs | 27 +++++++++ .../UpdateManifest/UpdateManifestModModel.cs | 26 +++++++++ .../UpdateManifest/UpdateManifestModPage.cs | 58 ++++++++++++++++++++ .../Clients/UpdateManifest/UpdateManifestModel.cs | 23 ++++++++ .../UpdateManifest/UpdateManifestVersionModel.cs | 26 +++++++++ src/SMAPI.Web/Framework/IModDownload.cs | 7 ++- src/SMAPI.Web/Framework/IModPage.cs | 17 ++++++ src/SMAPI.Web/Framework/ModInfoModel.cs | 11 +++- src/SMAPI.Web/Framework/ModSiteManager.cs | 64 ++++++++++++++-------- src/SMAPI.Web/SMAPI.Web.csproj | 4 ++ src/SMAPI.Web/Startup.cs | 3 + 18 files changed, 359 insertions(+), 36 deletions(-) create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs create mode 100644 src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs index 47cd3f7e..d1dd9049 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -19,6 +19,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData ModDrop, /// The Nexus Mods mod repository. - Nexus + Nexus, + + /// An arbitrary URL for an update manifest file. + UpdateManifest } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 71fb42c2..1c34f2af 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -22,6 +22,7 @@ using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; +using StardewModdingAPI.Web.Framework.Clients.UpdateManifest; using StardewModdingAPI.Web.Framework.ConfigModels; namespace StardewModdingAPI.Web.Controllers @@ -63,14 +64,15 @@ namespace StardewModdingAPI.Web.Controllers /// The GitHub API client. /// The ModDrop API client. /// The Nexus API client. - public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + /// The UpdateManifest client. + public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); this.WikiCache = wikiCache; this.ModCache = modCache; this.Config = config; - this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus }); + this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus, updateManifest }); } /// Fetch version metadata for the given mods. @@ -161,18 +163,22 @@ namespace StardewModdingAPI.Web.Controllers // if there's only a prerelease version (e.g. from GitHub), don't override the main version ISemanticVersion? curMain = data.Version; + string? curMainUrl = data.MainVersionUrl; ISemanticVersion? curPreview = data.PreviewVersion; + string? curPreviewUrl = data.PreviewVersionUrl; if (curPreview == null && curMain?.IsPrerelease() == true) { curPreview = curMain; + curPreviewUrl = curMainUrl; curMain = null; + curMainUrl = null; } // handle versions if (this.IsNewer(curMain, main?.Version)) - main = new ModEntryVersionModel(curMain, data.Url!); + main = new ModEntryVersionModel(curMain, curMainUrl ?? data.Url!); if (this.IsNewer(curPreview, optional?.Version)) - optional = new ModEntryVersionModel(curPreview, data.Url!); + optional = new ModEntryVersionModel(curPreview, curPreviewUrl ?? data.Url!); } // get unofficial version diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index b37e5cda..5cc03aba 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -17,6 +17,10 @@ namespace StardewModdingAPI.Web.Framework.Clients /// The download's file version. public string? Version { get; } + /// + /// The URL for this download, if it has one distinct from the mod page's URL. + /// + public string? Url { get; } /********* ** Public methods @@ -25,21 +29,22 @@ namespace StardewModdingAPI.Web.Framework.Clients /// The download's display name. /// The download's description. /// The download's file version. - public GenericModDownload(string name, string? description, string? version) + /// The download's URL (if different from the mod page's URL). + public GenericModDownload(string name, string? description, string? version, string? url = null) { this.Name = name; this.Description = description; this.Version = version; + this.Url = url; } /// - /// Return true if the subkey matches this download. A subkey matches if it appears as + /// Return if the subkey matches this download. A subkey matches if it appears as /// a substring in the name or description. /// /// the subkey - /// true if matches this download, otherwise false - /// - public bool MatchesSubkey(string subkey) { + /// if matches this download, otherwise + public virtual bool MatchesSubkey(string subkey) { return this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true || this.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true; } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 5353c7e1..4c66e1a0 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -40,6 +40,8 @@ namespace StardewModdingAPI.Web.Framework.Clients [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] public bool IsValid => this.Status == RemoteModStatus.Ok; + /// Whether to use strict subkey matching or not. + public bool IsSubkeyStrict { get; set; } = false; /********* ** Public methods @@ -79,5 +81,19 @@ namespace StardewModdingAPI.Web.Framework.Clients return this; } + + /// Returns the mod page name. + /// ignored + /// The mod page name. + public virtual string? GetName(string? subkey) { + return this.Name; + } + + /// Returns the mod page URL. + /// ignored + /// The mod page URL. + public virtual string? GetUrl(string? subkey) { + return this.Url; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs new file mode 100644 index 00000000..36d018c7 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs @@ -0,0 +1,9 @@ +// Copyright 2022 Jamie Taylor +using System; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// An HTTP client for fetching an update manifest from an arbitrary URL. + internal interface IUpdateManifestClient : IModSiteClient, IDisposable { } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs new file mode 100644 index 00000000..48e3c294 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs @@ -0,0 +1,15 @@ +// Copyright 2022 Jamie Taylor +using System.Net.Http.Formatting; +using System.Net.Http.Headers; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// + /// A that can parse from content of type text/plain. + /// + internal class TextAsJsonMediaTypeFormatter : JsonMediaTypeFormatter { + /// Construct a new + public TextAsJsonMediaTypeFormatter() { + this.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain")); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs new file mode 100644 index 00000000..d7cf4945 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs @@ -0,0 +1,55 @@ +// Copyright 2022 Jamie Taylor +using System; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using System.Threading.Tasks; +using System.Net; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// An HTTP client for fetching an update manifest from an arbitrary URL. + internal class UpdateManifestClient : IUpdateManifestClient { + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.UpdateManifest; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + public UpdateManifestClient(string userAgent) { + this.Client = new FluentClient() + .SetUserAgent(userAgent); + this.Client.Formatters.Add(new TextAsJsonMediaTypeFormatter()); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() { + this.Client.Dispose(); + } + + /// + public async Task GetModData(string id) { + UpdateManifestModel? manifest; + try { + manifest = await this.Client.GetAsync(id).As(); + } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); + } + if (manifest is null) { + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"Error parsing manifest at {id}"); + } + + return new UpdateManifestModPage(id, manifest); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs new file mode 100644 index 00000000..117ae15c --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs @@ -0,0 +1,27 @@ +// Copyright 2022 Jamie Taylor +using System; +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// Metadata about a mod download in an update manifest file. + internal class UpdateManifestModDownload : GenericModDownload { + /// The subkey for this mod download + private readonly string subkey; + /// Construct an instance. + /// The subkey for this download. + /// The mod name for this download. + /// The download's version. + /// The download's URL. + public UpdateManifestModDownload(string subkey, string name, string? version, string? url) : base(name, null, version, url) { + this.subkey = subkey; + } + + /// + /// Returns iff the given subkey is the same as the subkey for this download. + /// + /// The subkey to match + /// if is the same as the subkey for this download, otherwise. + public override bool MatchesSubkey(string subkey) { + return this.subkey == subkey; + } + } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs new file mode 100644 index 00000000..4ec9c03d --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs @@ -0,0 +1,26 @@ +// Copyright 2022 Jamie Taylor +using System; +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// Data model for a mod in an update manifest. + internal class UpdateManifestModModel { + /// The mod's name. + public string Name { get; } + + /// The mod's URL. + public string? Url { get; } + + /// The versions for this mod. + public UpdateManifestVersionModel[] Versions { get; } + + /// Construct an instance. + /// The mod's name. + /// The mod's URL. + /// The versions for this mod. + public UpdateManifestModModel(string name, string? url, UpdateManifestVersionModel[] versions) { + this.Name = name; + this.Url = url; + this.Versions = versions; + } + } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs new file mode 100644 index 00000000..109175b5 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs @@ -0,0 +1,58 @@ +// Copyright 2022 Jamie Taylor +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// Metadata about an update manifest "page". + internal class UpdateManifestModPage : GenericModPage { + /// The update manifest model. + private UpdateManifestModel manifest; + + /// Constuct an instance. + /// The "id" (i.e., URL) of this update manifest. + /// The manifest object model. + public UpdateManifestModPage(string id, UpdateManifestModel manifest) : base(ModSiteKey.UpdateManifest, id) { + this.IsSubkeyStrict = true; + this.manifest = manifest; + this.SetInfo(name: id, url: id, version: null, downloads: TranslateDownloads(manifest).ToArray()); + } + + /// Return the mod name for the given subkey, if it exists in this update manifest. + /// The subkey. + /// The mod name for the given subkey, or if this manifest does not contain the given subkey. + public override string? GetName(string? subkey) { + if (subkey is null) + return null; + this.manifest.Subkeys.TryGetValue(subkey, out UpdateManifestModModel? modModel); + return modModel?.Name; + } + + /// Return the mod URL for the given subkey, if it exists in this update manifest. + /// The subkey. + /// The mod URL for the given subkey, or if this manifest does not contain the given subkey. + public override string? GetUrl(string? subkey) { + if (subkey is null) + return null; + this.manifest.Subkeys.TryGetValue(subkey, out UpdateManifestModModel? modModel); + return modModel?.Url; + } + + + /********* + ** Private methods + *********/ + /// Translate the downloads from the manifest's object model into objects. + /// The manifest object model. + /// An for each in the manifest. + private static IEnumerable TranslateDownloads(UpdateManifestModel manifest) { + foreach (var entry in manifest.Subkeys) { + foreach (var version in entry.Value.Versions) { + yield return new UpdateManifestModDownload(entry.Key, entry.Value.Name, version.Version, version.DownloadFileUrl ?? version.DownloadPageUrl); + } + } + } + + } +} \ No newline at end of file diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs new file mode 100644 index 00000000..03f89726 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs @@ -0,0 +1,23 @@ +// Copyright 2022 Jamie Taylor +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// Data model for an update manifest. + internal class UpdateManifestModel { + /// The manifest format version. + public string ManifestVersion { get; } + + /// The subkeys in this update manifest. + public IDictionary Subkeys { get; } + + /// Construct an instance. + /// The manifest format version. + /// The subkeys in this update manifest. + public UpdateManifestModel(string manifestVersion, IDictionary subkeys) { + this.ManifestVersion = manifestVersion; + this.Subkeys = subkeys; + } + } +} + diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs new file mode 100644 index 00000000..55b6db61 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs @@ -0,0 +1,26 @@ +// Copyright 2022 Jamie Taylor +using System; +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { + /// Data model for a Version in an update manifest. + internal class UpdateManifestVersionModel { + /// The semantic version string. + public string Version { get; } + + /// The URL for this version's download page (if any). + public string? DownloadPageUrl { get; } + + /// The URL for this version's direct file download (if any). + public string? DownloadFileUrl { get; } + + /// Construct an instance. + /// The semantic version string. + /// This version's download page URL (if any). + /// This version's direct file download URL (if any). + public UpdateManifestVersionModel(string version, string? downloadPageUrl, string? downloadFileUrl) { + this.Version = version; + this.DownloadPageUrl = downloadPageUrl; + this.DownloadFileUrl = downloadFileUrl; + } + } +} + diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index 13739f8f..b0e9a664 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -15,9 +15,12 @@ namespace StardewModdingAPI.Web.Framework /// The download's file version. string? Version { get; } - /// Return true iff the subkey matches this download + /// This download's URL (if it has a URL that is different from the containing mod page's URL). + string? Url { get; } + + /// Return iff the subkey matches this download /// the subkey - /// true if matches this download, otherwise false + /// if matches this download, otherwise bool MatchesSubkey(string subkey); } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index 4d0a8d61..3fc8bb81 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -39,10 +39,27 @@ namespace StardewModdingAPI.Web.Framework [MemberNotNullWhen(false, nameof(IModPage.Error))] bool IsValid { get; } + /// + /// Does this page use strict subkey matching. Pages that use string subkey matching do not fall back + /// to searching for versions without a subkey if there are no versions found when given a subkey. + /// Additionally, the leading @ is stripped from the subkey value before searching for matches. + /// + bool IsSubkeyStrict { get; } /********* ** Methods *********/ + + /// Get the mod name associated with the given subkey, if any. + /// The subkey. + /// The mod name associated with the given subkey (if any) + string? GetName(string? subkey); + + /// Get the URL for the mod associated with the given subkey, if any. + /// The subkey. + /// The URL for the mod associated with the given subkey (if any) + string? GetUrl(string? subkey); + /// Set the fetched mod info. /// The mod name. /// The mod's semantic version number. diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index e70b60bf..17415589 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -27,6 +27,11 @@ namespace StardewModdingAPI.Web.Framework /// The error message indicating why the mod is invalid (if applicable). public string? Error { get; private set; } + /// The URL associated with the mod's latest version (if distinct from the mod page's URL). + public string? MainVersionUrl { get; private set; } + + /// The URL associated with the mod's (if distinct from the mod page's URL). + public string? PreviewVersionUrl { get; private set; } /********* ** Public methods @@ -65,11 +70,15 @@ namespace StardewModdingAPI.Web.Framework /// Set the mod version info. /// The semantic version for the mod's latest release. /// The semantic version for the mod's latest preview release, if available and different from . + /// The URL associated with , if different from the mod page's URL. + /// The URL associated with , if different from the mod page's URL. [MemberNotNull(nameof(ModInfoModel.Version))] - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null) + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null, string? mainVersionUrl = null, string? previewVersionUrl = null) { this.Version = version; this.PreviewVersion = previewVersion; + this.MainVersionUrl = mainVersionUrl; + this.PreviewVersionUrl = previewVersionUrl; return this; } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 70070d63..5581d799 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -66,23 +66,25 @@ namespace StardewModdingAPI.Web.Framework { // get base model ModInfoModel model = new(); - if (page.IsValid) - model.SetBasicInfo(page.Name, page.Url); - else - { + if (!page.IsValid) { model.SetError(page.Status, page.Error); return model; } + if (page.IsSubkeyStrict && subkey is not null) { + if (subkey.Length > 0 && subkey[0] == '@') { + subkey = subkey.Substring(1); + } + } + // fetch versions - bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion); - if (!hasVersions && subkey != null) - hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainVersionUrl, out string? previewVersionUrl); if (!hasVersions) - return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); + return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}{subkey ?? ""}' has no valid versions."); + model.SetBasicInfo(page.GetName(subkey) ?? page.Name, page.GetUrl(subkey) ?? page.Url); // return info - return model.SetVersions(mainVersion!, previewVersion); + return model.SetVersions(mainVersion!, previewVersion, mainVersionUrl, previewVersionUrl); } /// Get a semantic local version for update checks. @@ -113,10 +115,12 @@ namespace StardewModdingAPI.Web.Framework /// The changes to apply to remote versions for update checks. /// The main mod version. /// The latest prerelease version, if newer than . - private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview) + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainVersionUrl, out string? previewVersionUrl) { main = null; preview = null; + mainVersionUrl = null; + previewVersionUrl = null; // parse all versions from the mod page IEnumerable<(IModDownload? download, ISemanticVersion? version)> GetAllVersions() @@ -132,7 +136,7 @@ namespace StardewModdingAPI.Web.Framework // get mod version ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); if (modVersion != null) - yield return (download: null, version: ParseAndMapVersion(mod.Version)); + yield return (download: null, version: modVersion); // get file versions foreach (IModDownload download in mod.Downloads) @@ -148,10 +152,12 @@ namespace StardewModdingAPI.Web.Framework .ToArray(); // get main + preview versions - void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainVersionUrl, out string? previewVersionUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; + mainVersionUrl = null; + previewVersionUrl = null; // get latest main + preview version foreach ((IModDownload? download, ISemanticVersion? version) entry in versions) @@ -159,28 +165,40 @@ namespace StardewModdingAPI.Web.Framework if (entry.version is null || filter?.Invoke(entry) == false) continue; - if (entry.version.IsPrerelease()) - previewVersion ??= entry.version; - else - mainVersion ??= entry.version; - - if (mainVersion != null) + if (entry.version.IsPrerelease()) { + if (previewVersion is null) { + previewVersion = entry.version; + previewVersionUrl = entry.download?.Url; + } + } else { + mainVersion = entry.version; + mainVersionUrl = entry.download?.Url; break; // any others will be older since entries are sorted by version + } } // normalize values if (previewVersion is not null) { - mainVersion ??= previewVersion; // if every version is prerelease, latest one is the main version - if (!previewVersion.IsNewerThan(mainVersion)) + if (mainVersion is null) { + // if every version is prerelease, latest one is the main version + mainVersion = previewVersion; + mainVersionUrl = previewVersionUrl; + } + if (!previewVersion.IsNewerThan(mainVersion)) { previewVersion = null; + previewVersionUrl = null; + } } } - if (subkey is not null) - TryGetVersions(out main, out preview, entry => entry.download?.MatchesSubkey(subkey) == true); + if (subkey is not null) { + TryGetVersions(out main, out preview, out mainVersionUrl, out previewVersionUrl, entry => entry.download?.MatchesSubkey(subkey) == true); + if (mod?.IsSubkeyStrict == true) + return main != null; + } if (main is null) - TryGetVersions(out main, out preview); + TryGetVersions(out main, out preview, out mainVersionUrl, out previewVersionUrl); return main != null; } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index d26cb6f8..cfec1d08 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -12,6 +12,7 @@ + @@ -48,4 +49,7 @@ PreserveNewest + + + diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 54c25979..a068a998 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -20,6 +20,7 @@ using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Clients.UpdateManifest; using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.RedirectRules; @@ -149,6 +150,8 @@ namespace StardewModdingAPI.Web baseUrl: api.PastebinBaseUrl, userAgent: userAgent )); + + services.AddSingleton(new UpdateManifestClient(userAgent: userAgent)); } // init helpers -- cgit From 55fd4839da43e7ca205eaa85480786e3dfe8af6f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 29 Jan 2023 16:37:22 -0500 Subject: minor formatting, copyediting, and error-handling --- .../Framework/UpdateData/ModSiteKey.cs | 2 +- .../Framework/UpdateData/UpdateKey.cs | 47 ++++++------- src/SMAPI.Web/Controllers/ModsApiController.cs | 6 +- .../Framework/Clients/GenericModDownload.cs | 27 ++++---- src/SMAPI.Web/Framework/Clients/GenericModPage.cs | 19 ++--- .../UpdateManifest/IUpdateManifestClient.cs | 3 +- .../UpdateManifest/TextAsJsonMediaTypeFormatter.cs | 18 +++-- .../Clients/UpdateManifest/UpdateManifestClient.cs | 37 ++++++---- .../UpdateManifest/UpdateManifestModDownload.cs | 40 ++++++----- .../UpdateManifest/UpdateManifestModModel.cs | 32 +++++---- .../UpdateManifest/UpdateManifestModPage.cs | 80 +++++++++++++--------- .../Clients/UpdateManifest/UpdateManifestModel.cs | 30 +++++--- .../UpdateManifest/UpdateManifestVersionModel.cs | 28 +++++--- src/SMAPI.Web/Framework/IModDownload.cs | 13 ++-- src/SMAPI.Web/Framework/IModPage.cs | 18 ++--- src/SMAPI.Web/Framework/ModInfoModel.cs | 37 ++++++---- src/SMAPI.Web/Framework/ModSiteManager.cs | 72 ++++++++++++------- 17 files changed, 293 insertions(+), 216 deletions(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs index d1dd9049..195b0367 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// The Nexus Mods mod repository. Nexus, - /// An arbitrary URL for an update manifest file. + /// An arbitrary URL to a JSON file containing update data. UpdateManifest } } diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 7c2cc73c..3e8064fd 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -54,29 +54,6 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData public UpdateKey(ModSiteKey site, string? id, string? subkey) : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } - /// - /// Split a string into two at a delimiter. If the delimiter does not appear in the string then the second - /// value of the returned tuple is null. Both returned strings are trimmed of whitespace. - /// - /// The string to split. - /// The character on which to split. - /// - /// If true then the second string returned will include the delimiter character - /// (provided that the string is not null) - /// - /// - /// A pair containing the string consisting of all characters in before the first - /// occurrence of , and a string consisting of all characters in - /// after the first occurrence of or null if the delimiter does not - /// exist in s. Both strings are trimmed of whitespace. - /// - private static (string, string?) Bifurcate(string s, char delimiter, bool keepDelimiter = false) { - int pos = s.IndexOf(delimiter); - if (pos < 0) - return (s.Trim(), null); - return (s.Substring(0, pos).Trim(), s.Substring(pos + (keepDelimiter ? 0 : 1)).Trim()); - } - /// Parse a raw update key. /// The raw update key to parse. public static UpdateKey Parse(string? raw) @@ -84,14 +61,14 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData if (raw is null) return new UpdateKey(raw, ModSiteKey.Unknown, null, null); // extract site + ID - (string rawSite, string? id) = Bifurcate(raw, ':'); - if (string.IsNullOrWhiteSpace(id)) + (string rawSite, string? id) = UpdateKey.SplitTwoParts(raw, ':'); + if (string.IsNullOrEmpty(id)) id = null; // extract subkey string? subkey = null; if (id != null) - (id, subkey) = Bifurcate(id, '@', true); + (id, subkey) = UpdateKey.SplitTwoParts(id, '@', true); // parse if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) @@ -160,5 +137,23 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData { return $"{site}:{id}{subkey}".Trim(); } + + + /********* + ** Private methods + *********/ + /// Split a string into two parts at a delimiter and trim whitespace. + /// The string to split. + /// The character on which to split. + /// Whether to include the delimiter in the second string. + /// Returns a tuple containing the two strings, with the second value null if the delimiter wasn't found. + private static (string, string?) SplitTwoParts(string str, char delimiter, bool keepDelimiter = false) + { + int splitIndex = str.IndexOf(delimiter); + + return splitIndex >= 0 + ? (str.Substring(0, splitIndex).Trim(), str.Substring(splitIndex + (keepDelimiter ? 0 : 1)).Trim()) + : (str.Trim(), null); + } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 1c34f2af..5fc4987d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -64,7 +64,7 @@ namespace StardewModdingAPI.Web.Controllers /// The GitHub API client. /// The ModDrop API client. /// The Nexus API client. - /// The UpdateManifest client. + /// The API client for arbitrary update manifest URLs. public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus, IUpdateManifestClient updateManifest) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); @@ -163,9 +163,9 @@ namespace StardewModdingAPI.Web.Controllers // if there's only a prerelease version (e.g. from GitHub), don't override the main version ISemanticVersion? curMain = data.Version; - string? curMainUrl = data.MainVersionUrl; ISemanticVersion? curPreview = data.PreviewVersion; - string? curPreviewUrl = data.PreviewVersionUrl; + string? curMainUrl = data.MainModPageUrl; + string? curPreviewUrl = data.PreviewModPageUrl; if (curPreview == null && curMain?.IsPrerelease() == true) { curPreview = curMain; diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index 5cc03aba..6c9c08ef 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -17,10 +17,9 @@ namespace StardewModdingAPI.Web.Framework.Clients /// The download's file version. public string? Version { get; } - /// - /// The URL for this download, if it has one distinct from the mod page's URL. - /// - public string? Url { get; } + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + public string? ModPageUrl { get; } + /********* ** Public methods @@ -29,23 +28,21 @@ namespace StardewModdingAPI.Web.Framework.Clients /// The download's display name. /// The download's description. /// The download's file version. - /// The download's URL (if different from the mod page's URL). - public GenericModDownload(string name, string? description, string? version, string? url = null) + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + public GenericModDownload(string name, string? description, string? version, string? modPageUrl = null) { this.Name = name; this.Description = description; this.Version = version; - this.Url = url; + this.ModPageUrl = modPageUrl; } - /// - /// Return if the subkey matches this download. A subkey matches if it appears as - /// a substring in the name or description. - /// - /// the subkey - /// if matches this download, otherwise - public virtual bool MatchesSubkey(string subkey) { - return this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true + /// Get whether the subkey matches this download. + /// The update subkey to check. + public virtual bool MatchesSubkey(string subkey) + { + return + this.Name.Contains(subkey, StringComparison.OrdinalIgnoreCase) || this.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true; } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 4c66e1a0..e939f1d8 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -40,9 +40,10 @@ namespace StardewModdingAPI.Web.Framework.Clients [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] public bool IsValid => this.Status == RemoteModStatus.Ok; - /// Whether to use strict subkey matching or not. + /// Whether this mod page requires string subkey matching, in which case a subkey that isn't found will return no update instead of falling back to one without. public bool IsSubkeyStrict { get; set; } = false; + /********* ** Public methods *********/ @@ -82,17 +83,17 @@ namespace StardewModdingAPI.Web.Framework.Clients return this; } - /// Returns the mod page name. - /// ignored - /// The mod page name. - public virtual string? GetName(string? subkey) { + /// Get the mod name for an update subkey, if different from the mod page name. + /// The update subkey. + public virtual string? GetName(string? subkey) + { return this.Name; } - /// Returns the mod page URL. - /// ignored - /// The mod page URL. - public virtual string? GetUrl(string? subkey) { + /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. + /// The update subkey. + public virtual string? GetUrl(string? subkey) + { return this.Url; } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs index 36d018c7..dd9f5811 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/IUpdateManifestClient.cs @@ -3,7 +3,6 @@ using System; namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { - /// An HTTP client for fetching an update manifest from an arbitrary URL. + /// An API client for fetching update metadata from an arbitrary JSON URL. internal interface IUpdateManifestClient : IModSiteClient, IDisposable { } } - diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs index 48e3c294..02722cb1 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/TextAsJsonMediaTypeFormatter.cs @@ -1,14 +1,18 @@ -// Copyright 2022 Jamie Taylor +// Copyright 2022 Jamie Taylor using System.Net.Http.Formatting; using System.Net.Http.Headers; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { - /// - /// A that can parse from content of type text/plain. - /// - internal class TextAsJsonMediaTypeFormatter : JsonMediaTypeFormatter { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// A that can parse from content of type text/plain. + internal class TextAsJsonMediaTypeFormatter : JsonMediaTypeFormatter + { + /********* + ** Public methods + *********/ /// Construct a new - public TextAsJsonMediaTypeFormatter() { + public TextAsJsonMediaTypeFormatter() + { this.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain")); } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs index d7cf4945..88a5c2f6 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestClient.cs @@ -1,19 +1,21 @@ // Copyright 2022 Jamie Taylor -using System; +using System.Net; +using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using System.Threading.Tasks; -using System.Net; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { - /// An HTTP client for fetching an update manifest from an arbitrary URL. - internal class UpdateManifestClient : IUpdateManifestClient { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// An API client for fetching update metadata from an arbitrary JSON URL. + internal class UpdateManifestClient : IUpdateManifestClient + { /********* ** Fields *********/ /// The underlying HTTP client. private readonly IClient Client; + /********* ** Accessors *********/ @@ -26,30 +28,35 @@ namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { *********/ /// Construct an instance. /// The user agent for the API client. - public UpdateManifestClient(string userAgent) { + public UpdateManifestClient(string userAgent) + { this.Client = new FluentClient() .SetUserAgent(userAgent); this.Client.Formatters.Add(new TextAsJsonMediaTypeFormatter()); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() { + public void Dispose() + { this.Client.Dispose(); } /// - public async Task GetModData(string id) { + public async Task GetModData(string id) + { UpdateManifestModel? manifest; - try { + try + { manifest = await this.Client.GetAsync(id).As(); - } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { - return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); } - if (manifest is null) { - return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"Error parsing manifest at {id}"); + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"No update manifest found at {id}"); } - return new UpdateManifestModPage(id, manifest); + return manifest is not null + ? new UpdateManifestModPage(id, manifest) + : new GenericModPage(this.SiteKey, id).SetError(RemoteModStatus.DoesNotExist, $"The update manifest at {id} has an invalid format"); } } } diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs index 117ae15c..0a6d4736 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModDownload.cs @@ -1,27 +1,35 @@ // Copyright 2022 Jamie Taylor -using System; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ /// Metadata about a mod download in an update manifest file. - internal class UpdateManifestModDownload : GenericModDownload { - /// The subkey for this mod download - private readonly string subkey; + internal class UpdateManifestModDownload : GenericModDownload + { + /********* + ** Fields + *********/ + /// The update subkey for this mod download. + private readonly string Subkey; + + + /********* + ** Public methods + *********/ /// Construct an instance. - /// The subkey for this download. + /// The field name for this mod download in the manifest. /// The mod name for this download. /// The download's version. /// The download's URL. - public UpdateManifestModDownload(string subkey, string name, string? version, string? url) : base(name, null, version, url) { - this.subkey = subkey; + public UpdateManifestModDownload(string fieldName, string name, string? version, string? url) + : base(name, null, version, url) + { + this.Subkey = fieldName; } - /// - /// Returns iff the given subkey is the same as the subkey for this download. - /// - /// The subkey to match - /// if is the same as the subkey for this download, otherwise. - public override bool MatchesSubkey(string subkey) { - return this.subkey == subkey; + /// Get whether the subkey matches this download. + /// The update subkey to check. + public override bool MatchesSubkey(string subkey) + { + return subkey == this.Subkey; } } } - diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs index 4ec9c03d..92642321 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModModel.cs @@ -1,26 +1,34 @@ // Copyright 2022 Jamie Taylor -using System; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { - /// Data model for a mod in an update manifest. - internal class UpdateManifestModModel { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// The data model for a mod in an update manifest file. + internal class UpdateManifestModModel + { + /********* + ** Accessors + *********/ /// The mod's name. - public string Name { get; } + public string? Name { get; } - /// The mod's URL. + /// The mod page URL from which to download updates. public string? Url { get; } - /// The versions for this mod. - public UpdateManifestVersionModel[] Versions { get; } + /// The available versions for this mod. + public UpdateManifestVersionModel[]? Versions { get; } + + /********* + ** Public methods + *********/ /// Construct an instance. /// The mod's name. - /// The mod's URL. - /// The versions for this mod. - public UpdateManifestModModel(string name, string? url, UpdateManifestVersionModel[] versions) { + /// The mod page URL from which to download updates. + /// The available versions for this mod. + public UpdateManifestModModel(string? name, string? url, UpdateManifestVersionModel[]? versions) + { this.Name = name; this.Url = url; this.Versions = versions; } } } - diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs index 109175b5..bbc7b5da 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModPage.cs @@ -1,58 +1,74 @@ -// Copyright 2022 Jamie Taylor -using System; +// Copyright 2022 Jamie Taylor using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ /// Metadata about an update manifest "page". - internal class UpdateManifestModPage : GenericModPage { - /// The update manifest model. - private UpdateManifestModel manifest; - - /// Constuct an instance. - /// The "id" (i.e., URL) of this update manifest. - /// The manifest object model. - public UpdateManifestModPage(string id, UpdateManifestModel manifest) : base(ModSiteKey.UpdateManifest, id) { + internal class UpdateManifestModPage : GenericModPage + { + /********* + ** Fields + *********/ + /// The mods from the update manifest. + private readonly IDictionary Mods; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The URL of the update manifest file. + /// The parsed update manifest. + public UpdateManifestModPage(string url, UpdateManifestModel manifest) + : base(ModSiteKey.UpdateManifest, url) + { this.IsSubkeyStrict = true; - this.manifest = manifest; - this.SetInfo(name: id, url: id, version: null, downloads: TranslateDownloads(manifest).ToArray()); + this.Mods = manifest.Mods ?? new Dictionary(); + this.SetInfo(name: url, url: url, version: null, downloads: this.ParseDownloads(manifest.Mods).ToArray()); } /// Return the mod name for the given subkey, if it exists in this update manifest. /// The subkey. /// The mod name for the given subkey, or if this manifest does not contain the given subkey. - public override string? GetName(string? subkey) { - if (subkey is null) - return null; - this.manifest.Subkeys.TryGetValue(subkey, out UpdateManifestModModel? modModel); - return modModel?.Name; + public override string? GetName(string? subkey) + { + return subkey is not null && this.Mods.TryGetValue(subkey, out UpdateManifestModModel? modModel) + ? modModel.Name + : null; } /// Return the mod URL for the given subkey, if it exists in this update manifest. /// The subkey. /// The mod URL for the given subkey, or if this manifest does not contain the given subkey. - public override string? GetUrl(string? subkey) { - if (subkey is null) - return null; - this.manifest.Subkeys.TryGetValue(subkey, out UpdateManifestModModel? modModel); - return modModel?.Url; + public override string? GetUrl(string? subkey) + { + return subkey is not null && this.Mods.TryGetValue(subkey, out UpdateManifestModModel? modModel) + ? modModel.Url + : null; } /********* ** Private methods *********/ - /// Translate the downloads from the manifest's object model into objects. - /// The manifest object model. - /// An for each in the manifest. - private static IEnumerable TranslateDownloads(UpdateManifestModel manifest) { - foreach (var entry in manifest.Subkeys) { - foreach (var version in entry.Value.Versions) { - yield return new UpdateManifestModDownload(entry.Key, entry.Value.Name, version.Version, version.DownloadFileUrl ?? version.DownloadPageUrl); - } + /// Convert the raw download info from an update manifest to . + /// The mods from the update manifest. + private IEnumerable ParseDownloads(IDictionary? mods) + { + if (mods is null) + yield break; + + foreach ((string modKey, UpdateManifestModModel mod) in mods) + { + if (mod.Versions is null) + continue; + + foreach (UpdateManifestVersionModel version in mod.Versions) + yield return new UpdateManifestModDownload(modKey, mod.Name ?? modKey, version.Version, version.DownloadFileUrl ?? version.DownloadPageUrl); } } } -} \ No newline at end of file +} diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs index 03f89726..ad618022 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestModel.cs @@ -1,23 +1,31 @@ // Copyright 2022 Jamie Taylor -using System; using System.Collections.Generic; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { - /// Data model for an update manifest. - internal class UpdateManifestModel { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ + /// The data model for an update manifest file. + internal class UpdateManifestModel + { + /********* + ** Accessors + *********/ /// The manifest format version. - public string ManifestVersion { get; } + public string? ManifestVersion { get; } - /// The subkeys in this update manifest. - public IDictionary Subkeys { get; } + /// The mod info in this update manifest. + public IDictionary? Mods { get; } + + /********* + ** Public methods + *********/ /// Construct an instance. /// The manifest format version. - /// The subkeys in this update manifest. - public UpdateManifestModel(string manifestVersion, IDictionary subkeys) { + /// The mod info in this update manifest. + public UpdateManifestModel(string manifestVersion, IDictionary mods) + { this.ManifestVersion = manifestVersion; - this.Subkeys = subkeys; + this.Mods = mods; } } } - diff --git a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs index 55b6db61..90e054d8 100644 --- a/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs +++ b/src/SMAPI.Web/Framework/Clients/UpdateManifest/UpdateManifestVersionModel.cs @@ -1,10 +1,14 @@ // Copyright 2022 Jamie Taylor -using System; -namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { +namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest +{ /// Data model for a Version in an update manifest. - internal class UpdateManifestVersionModel { - /// The semantic version string. - public string Version { get; } + internal class UpdateManifestVersionModel + { + /********* + ** Accessors + *********/ + /// The mod's semantic version. + public string? Version { get; } /// The URL for this version's download page (if any). public string? DownloadPageUrl { get; } @@ -12,15 +16,19 @@ namespace StardewModdingAPI.Web.Framework.Clients.UpdateManifest { /// The URL for this version's direct file download (if any). public string? DownloadFileUrl { get; } + + /********* + ** Public methods + *********/ /// Construct an instance. - /// The semantic version string. - /// This version's download page URL (if any). - /// This version's direct file download URL (if any). - public UpdateManifestVersionModel(string version, string? downloadPageUrl, string? downloadFileUrl) { + /// The mod's semantic version. + /// This version's download page URL, if any. + /// This version's direct file download URL, if any. + public UpdateManifestVersionModel(string version, string? downloadPageUrl, string? downloadFileUrl) + { this.Version = version; this.DownloadPageUrl = downloadPageUrl; this.DownloadFileUrl = downloadFileUrl; } } } - diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index b0e9a664..8cb82989 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -15,12 +15,15 @@ namespace StardewModdingAPI.Web.Framework /// The download's file version. string? Version { get; } - /// This download's URL (if it has a URL that is different from the containing mod page's URL). - string? Url { get; } + /// The mod URL page from which to download this update, if different from the URL of the mod page it was fetched from. + string? ModPageUrl { get; } - /// Return iff the subkey matches this download - /// the subkey - /// if matches this download, otherwise + + /********* + ** Methods + *********/ + /// Get whether the subkey matches this download. + /// The update subkey to check. bool MatchesSubkey(string subkey); } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index 3fc8bb81..ef1513eb 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -39,25 +39,19 @@ namespace StardewModdingAPI.Web.Framework [MemberNotNullWhen(false, nameof(IModPage.Error))] bool IsValid { get; } - /// - /// Does this page use strict subkey matching. Pages that use string subkey matching do not fall back - /// to searching for versions without a subkey if there are no versions found when given a subkey. - /// Additionally, the leading @ is stripped from the subkey value before searching for matches. - /// + /// Whether this mod page requires string subkey matching, in which case a subkey that isn't found will return no update instead of falling back to one without. Additionally, the leading @ is stripped from the subkey value before searching for matches. bool IsSubkeyStrict { get; } + /********* ** Methods *********/ - - /// Get the mod name associated with the given subkey, if any. - /// The subkey. - /// The mod name associated with the given subkey (if any) + /// Get the mod name for an update subkey, if different from the mod page name. + /// The update subkey. string? GetName(string? subkey); - /// Get the URL for the mod associated with the given subkey, if any. - /// The subkey. - /// The URL for the mod associated with the given subkey (if any) + /// Get the mod page URL for an update subkey, if different from the mod page it was fetched from. + /// The update subkey. string? GetUrl(string? subkey); /// Set the fetched mod info. diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index 17415589..502c0827 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -27,11 +27,12 @@ namespace StardewModdingAPI.Web.Framework /// The error message indicating why the mod is invalid (if applicable). public string? Error { get; private set; } - /// The URL associated with the mod's latest version (if distinct from the mod page's URL). - public string? MainVersionUrl { get; private set; } + /// The mod page URL from which can be downloaded, if different from the . + public string? MainModPageUrl { get; private set; } + + /// The mod page URL from which can be downloaded, if different from the . + public string? PreviewModPageUrl { get; private set; } - /// The URL associated with the mod's (if distinct from the mod page's URL). - public string? PreviewVersionUrl { get; private set; } /********* ** Public methods @@ -51,7 +52,8 @@ namespace StardewModdingAPI.Web.Framework { this .SetBasicInfo(name, url) - .SetVersions(version!, previewVersion) + .SetMainVersion(version!) + .SetPreviewVersion(previewVersion) .SetError(status, error!); } @@ -67,18 +69,25 @@ namespace StardewModdingAPI.Web.Framework return this; } - /// Set the mod version info. - /// The semantic version for the mod's latest release. - /// The semantic version for the mod's latest preview release, if available and different from . - /// The URL associated with , if different from the mod page's URL. - /// The URL associated with , if different from the mod page's URL. + /// Set the mod's main version info. + /// The semantic version for the mod's latest stable release. + /// The mod page URL from which can be downloaded, if different from the . [MemberNotNull(nameof(ModInfoModel.Version))] - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null, string? mainVersionUrl = null, string? previewVersionUrl = null) + public ModInfoModel SetMainVersion(ISemanticVersion version, string? modPageUrl = null) { this.Version = version; - this.PreviewVersion = previewVersion; - this.MainVersionUrl = mainVersionUrl; - this.PreviewVersionUrl = previewVersionUrl; + this.MainModPageUrl = modPageUrl; + + return this; + } + + /// Set the mod's preview version info. + /// The semantic version for the mod's latest preview release. + /// The mod page URL from which can be downloaded, if different from the . + public ModInfoModel SetPreviewVersion(ISemanticVersion? version, string? modPageUrl = null) + { + this.PreviewVersion = version; + this.PreviewModPageUrl = modPageUrl; return this; } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 5581d799..350159a3 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -66,25 +66,34 @@ namespace StardewModdingAPI.Web.Framework { // get base model ModInfoModel model = new(); - if (!page.IsValid) { + if (!page.IsValid) + { model.SetError(page.Status, page.Error); return model; } - if (page.IsSubkeyStrict && subkey is not null) { - if (subkey.Length > 0 && subkey[0] == '@') { + // trim subkey in strict mode + if (page.IsSubkeyStrict && subkey is not null) + { + if (subkey.StartsWith('@')) subkey = subkey.Substring(1); - } } // fetch versions - bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainVersionUrl, out string? previewVersionUrl); + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainModPageUrl, out string? previewModPageUrl); if (!hasVersions) return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}{subkey ?? ""}' has no valid versions."); - model.SetBasicInfo(page.GetName(subkey) ?? page.Name, page.GetUrl(subkey) ?? page.Url); + // apply mod page info + model.SetBasicInfo( + name: page.GetName(subkey) ?? page.Name, + url: page.GetUrl(subkey) ?? page.Url + ); + // return info - return model.SetVersions(mainVersion!, previewVersion, mainVersionUrl, previewVersionUrl); + return model + .SetMainVersion(mainVersion!, mainModPageUrl) + .SetPreviewVersion(previewVersion, previewModPageUrl); } /// Get a semantic local version for update checks. @@ -115,12 +124,14 @@ namespace StardewModdingAPI.Web.Framework /// The changes to apply to remote versions for update checks. /// The main mod version. /// The latest prerelease version, if newer than . - private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainVersionUrl, out string? previewVersionUrl) + /// The mod page URL from which can be downloaded, if different from the 's URL. + /// The mod page URL from which can be downloaded, if different from the 's URL. + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainModPageUrl, out string? previewModPageUrl) { main = null; preview = null; - mainVersionUrl = null; - previewVersionUrl = null; + mainModPageUrl = null; + previewModPageUrl = null; // parse all versions from the mod page IEnumerable<(IModDownload? download, ISemanticVersion? version)> GetAllVersions() @@ -152,12 +163,12 @@ namespace StardewModdingAPI.Web.Framework .ToArray(); // get main + preview versions - void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainVersionUrl, out string? previewVersionUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainUrl, out string? previewUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; - mainVersionUrl = null; - previewVersionUrl = null; + mainUrl = null; + previewUrl = null; // get latest main + preview version foreach ((IModDownload? download, ISemanticVersion? version) entry in versions) @@ -165,14 +176,18 @@ namespace StardewModdingAPI.Web.Framework if (entry.version is null || filter?.Invoke(entry) == false) continue; - if (entry.version.IsPrerelease()) { - if (previewVersion is null) { + if (entry.version.IsPrerelease()) + { + if (previewVersion is null) + { previewVersion = entry.version; - previewVersionUrl = entry.download?.Url; + previewUrl = entry.download?.ModPageUrl; } - } else { + } + else + { mainVersion = entry.version; - mainVersionUrl = entry.download?.Url; + mainUrl = entry.download?.ModPageUrl; break; // any others will be older since entries are sorted by version } } @@ -180,26 +195,31 @@ namespace StardewModdingAPI.Web.Framework // normalize values if (previewVersion is not null) { - if (mainVersion is null) { + if (mainVersion is null) + { // if every version is prerelease, latest one is the main version mainVersion = previewVersion; - mainVersionUrl = previewVersionUrl; + mainUrl = previewUrl; } - if (!previewVersion.IsNewerThan(mainVersion)) { + if (!previewVersion.IsNewerThan(mainVersion)) + { previewVersion = null; - previewVersionUrl = null; + previewUrl = null; } } } - if (subkey is not null) { - TryGetVersions(out main, out preview, out mainVersionUrl, out previewVersionUrl, entry => entry.download?.MatchesSubkey(subkey) == true); + // get versions for subkey + if (subkey is not null) + { + TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl, filter: entry => entry.download?.MatchesSubkey(subkey) == true); if (mod?.IsSubkeyStrict == true) return main != null; } - if (main is null) - TryGetVersions(out main, out preview, out mainVersionUrl, out previewVersionUrl); + // fallback to non-subkey versions + if (main is null) + TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl); return main != null; } -- cgit