From c776f6053bd0b9db909ebda2853a86c1cd21c2cf Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 16 May 2020 11:33:17 -0400 Subject: update deprecated code --- src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/SMAPI.Web/Framework/Caching/Mods') diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs index 2e7804a7..d6906866 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs @@ -88,7 +88,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods this.Mods.ReplaceOne( entry => entry.ID == id && entry.Site == mod.Site, mod, - new UpdateOptions { IsUpsert = true } + new ReplaceOptions { IsUpsert = true } ); return mod; -- cgit From a2cfb71d898aca98d621f1b86dd5611337eea034 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 16 May 2020 11:34:00 -0400 Subject: minor cleanup --- src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs | 2 +- src/SMAPI.Web/Framework/LogParsing/LogParser.cs | 6 +++--- src/SMAPI.Web/Views/Mods/Index.cshtml | 2 +- src/SMAPI.Web/Views/Shared/_Layout.cshtml | 2 +- src/SMAPI.Web/wwwroot/Content/js/mods.js | 2 -- src/SMAPI/Framework/Rendering/SDisplayDevice.cs | 13 +------------ src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs | 2 +- src/SMAPI/Framework/SGame.cs | 2 +- 8 files changed, 9 insertions(+), 22 deletions(-) (limited to 'src/SMAPI.Web/Framework/Caching/Mods') diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs index d6906866..6ba1d73d 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs @@ -72,7 +72,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods public void RemoveStaleMods(TimeSpan age) { DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); - var result = this.Mods.DeleteMany(p => p.LastRequested < minDate); + this.Mods.DeleteMany(p => p.LastRequested < minDate); } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index cce80816..227dcd89 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?.+?) (?[^\s]+): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// A regex pattern matching SMAPI's update line. - private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?[^\s]+): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SmapiUpdatePattern = new Regex(@"^You can update SMAPI to (?[^\s]+): (?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* @@ -181,9 +181,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing message.Section = LogSection.ModUpdateList; } - else if (message.Level == LogLevel.Alert && this.SMAPIUpdatePattern.IsMatch(message.Text)) + else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { - Match match = this.SMAPIUpdatePattern.Match(message.Text); + Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; smapiMod.UpdateVersion = version; diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index fa1375ec..c62e1466 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -83,7 +83,7 @@ else - + {{mod.Name}} (aka {{mod.AlternateNames}}) diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 2d06ceb1..67dcd3b3 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -29,7 +29,7 @@
- @if (ViewData["ViewTitle"] != string.Empty) + @if (ViewData["ViewTitle"] as string != string.Empty) {

@(ViewData["ViewTitle"] ?? ViewData["Title"])

} diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index 35098b60..ac2754a4 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -1,5 +1,3 @@ -/* globals $ */ - var smapi = smapi || {}; var app; smapi.modList = function (mods, enableBeta) { diff --git a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs index 382949bf..85e69ae6 100644 --- a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs @@ -2,7 +2,6 @@ using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; -using StardewValley; using xTile.Dimensions; using xTile.Layers; using xTile.ObjectModel; @@ -13,13 +12,6 @@ namespace StardewModdingAPI.Framework.Rendering /// A map display device which overrides the draw logic to support tile rotation. internal class SDisplayDevice : SXnaDisplayDevice { - /********* - ** Fields - *********/ - /// The origin to use when rotating tiles. - private readonly Vector2 RotationOrigin; - - /********* ** Public methods *********/ @@ -27,10 +19,7 @@ namespace StardewModdingAPI.Framework.Rendering /// The content manager through which to load tiles. /// The graphics device with which to render tiles. public SDisplayDevice(ContentManager contentManager, GraphicsDevice graphicsDevice) - : base(contentManager, graphicsDevice) - { - this.RotationOrigin = new Vector2((Game1.tileSize * Game1.pixelZoom) / 2f); - } + : base(contentManager, graphicsDevice) { } /// Draw a tile to the screen. /// The tile to draw. diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs index d4f62b4f..121e53bc 100644 --- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs @@ -10,7 +10,7 @@ using xTile.Layers; using xTile.Tiles; using Rectangle = xTile.Dimensions.Rectangle; -namespace StardewModdingAPI.Framework +namespace StardewModdingAPI.Framework.Rendering { /// A map display device which reimplements the default logic. /// This is an exact copy of , except that private fields are protected and all methods are virtual. diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 2a30b595..82db5857 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -1310,7 +1310,7 @@ namespace StardewModdingAPI.Framework } Game1.drawPlayerHeldObject(Game1.player); } - label_139: + label_139: if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) Game1.drawTool(Game1.player); if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) -- cgit From 5e6f1640dcb8e30a44f8ff07572874850b12cc2e Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 16 May 2020 14:30:07 -0400 Subject: simplify single-instance deployment and make MongoDB server optional --- docs/release-notes.md | 4 + docs/technical/web.md | 44 +++++++-- .../Framework/Caching/Mods/IModCacheRepository.cs | 2 +- .../Caching/Mods/ModCacheMemoryRepository.cs | 89 +++++++++++++++++ .../Caching/Mods/ModCacheMongoRepository.cs | 105 +++++++++++++++++++++ .../Framework/Caching/Mods/ModCacheRepository.cs | 104 -------------------- .../Framework/Caching/Wiki/IWikiCacheRepository.cs | 2 +- .../Caching/Wiki/WikiCacheMemoryRepository.cs | 54 +++++++++++ .../Caching/Wiki/WikiCacheMongoRepository.cs | 73 ++++++++++++++ .../Framework/Caching/Wiki/WikiCacheRepository.cs | 73 -------------- .../Framework/ConfigModels/MongoDbConfig.cs | 25 ----- .../Framework/ConfigModels/StorageConfig.cs | 18 ++++ .../Framework/ConfigModels/StorageMode.cs | 15 +++ src/SMAPI.Web/Startup.cs | 94 ++++++++++++------ src/SMAPI.Web/appsettings.Development.json | 3 +- src/SMAPI.Web/appsettings.json | 3 +- 16 files changed, 464 insertions(+), 244 deletions(-) create mode 100644 src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs create mode 100644 src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs create mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs create mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs create mode 100644 src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs create mode 100644 src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs (limited to 'src/SMAPI.Web/Framework/Caching/Mods') diff --git a/docs/release-notes.md b/docs/release-notes.md index a660888c..abe07dd9 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -8,12 +8,16 @@ * For the web UI: * Updated web framework to improve site performance and reliability. * Added GitHub licenses to mod compatibility list. + * Internal changes to improve performance and reliability. * For modders: * Added `Multiplayer.PeerConnected` event. * Simplified paranoid warnings in the log and reduced their log level. * Fixed asset propagation for Gil's portraits. +* For SMAPI developers: + * When deploying web services to a single-instance app, the MongoDB server can now be replaced with in-memory storage. + ## 3.5 Released 27 April 2020 for Stardew Valley 1.4.1 or later. diff --git a/docs/technical/web.md b/docs/technical/web.md index 67e86c8b..ef591aee 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -340,9 +340,20 @@ short url | → | target page A local environment lets you run a complete copy of the web project (including cache database) on your machine, with no external dependencies aside from the actual mod sites. -1. Enter the Nexus credentials in `appsettings.Development.json` . You can leave the other - credentials empty to default to fetching data anonymously, and storing data in-memory and - on disk. +1. Edit `appsettings.Development.json` and set these options: + + property name | description + ------------- | ----------- + `NexusApiKey` | [Your Nexus API key](https://www.nexusmods.com/users/myaccount?tab=api#personal_key). + + Optional settings: + + property name | description + --------------------------- | ----------- + `AzureBlobConnectionString` | The connection string for the Azure Blob storage account. Defaults to using the system's temporary file folder if not specified. + `GitHubUsername`
`GitHubPassword` | The GitHub credentials with which to query GitHub release info. Defaults to anonymous requests if not specified. + `Storage` | How to storage cached wiki/mod data. `InMemory` is recommended in most cases, or `MongoInMemory` to test the MongoDB storage code. See [production environment](#production-environment) for more info on `Mongo`. + 2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site. ### Production environment @@ -355,19 +366,15 @@ accordingly. Initial setup: -1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)) - for mod data. -2. Create an Azure Blob storage account for uploaded files. -3. Create an Azure App Services environment running the latest .NET Core on Linux or Windows. -4. Add these application settings in the new App Services environment: +1. Create an Azure Blob storage account for uploaded files. +2. Create an Azure App Services environment running the latest .NET Core on Linux or Windows. +3. Add these application settings in the new App Services environment: property name | description ------------------------------- | ----------------- `ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2. `ApiClients.GitHubUsername`
`ApiClients.GitHubPassword` | The login credentials for the GitHub account with which to fetch release info. If these are omitted, GitHub will impose much stricter rate limits. `ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client). - `MongoDB:ConnectionString` | The connection string for the MongoDB instance. - `MongoDB:Database` | The MongoDB database name (e.g. `smapi` in production or `smapi-edge` in testing environments). Optional settings: @@ -378,6 +385,23 @@ Initial setup: `Site:BetaBlurb` | If `Site:BetaEnabled` is true and there's a beta version of SMAPI in its GitHub releases, this is shown on the beta download button as explanatory subtext. `Site:SupporterList` | A list of Patreon supports to credit on the download page. +To enable distributed servers: + +1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)) + for mod data. +2. Add these application settings in the App Services environment: + + property name | description + ------------------------------- | ----------------- + `Storage:Mode` | Set to `Mongo`. + `Storage:ConnectionString` | Set to the connection string for the MongoDB instance. + + Optional settings: + + property name | description + ------------------------------- | ----------------- + `Storage:Database` | Set to the MongoDB database name (defaults to `smapi`). + To deploy updates: 1. [Deploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure). 2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.) diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index bcec8b36..08749f3b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Framework.Caching.Mods { - /// Encapsulates logic for accessing the mod data cache. + /// Manages cached mod data. internal interface IModCacheRepository : ICacheRepository { /********* diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs new file mode 100644 index 00000000..9c5a217e --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// Manages cached mod data in-memory. + internal class ModCacheMemoryRepository : BaseCacheRepository, IModCacheRepository + { + /********* + ** Fields + *********/ + /// The cached mod data indexed by {site key}:{ID}. + private readonly IDictionary Mods = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// Get the cached mod data. + /// The mod site to search. + /// The mod's unique ID within the . + /// The fetched mod. + /// Whether to update the mod's 'last requested' date. + public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) + { + // get mod + if (!this.Mods.TryGetValue(this.GetKey(site, id), out mod)) + return false; + + // bump 'last requested' + if (markRequested) + { + mod.LastRequested = DateTimeOffset.UtcNow; + mod = this.SaveMod(mod); + } + + return true; + } + + /// Save data fetched for a mod. + /// The mod site on which the mod is found. + /// The mod's unique ID within the . + /// The mod data. + /// The stored mod record. + public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) + { + string key = this.GetKey(site, id); + cachedMod = this.SaveMod(new CachedMod(site, id, mod)); + } + + /// Delete data for mods which haven't been requested within a given time limit. + /// The minimum age for which to remove mods. + public void RemoveStaleMods(TimeSpan age) + { + DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); + + string[] staleKeys = this.Mods + .Where(p => p.Value.LastRequested < minDate) + .Select(p => p.Key) + .ToArray(); + + foreach (string key in staleKeys) + this.Mods.Remove(key); + } + + /// Save data fetched for a mod. + /// The mod data. + public CachedMod SaveMod(CachedMod mod) + { + string key = this.GetKey(mod.Site, mod.ID); + return this.Mods[key] = mod; + } + + + /********* + ** Private methods + *********/ + /// Get a cache key. + /// The mod site. + /// The mod ID. + public string GetKey(ModRepositoryKey site, string id) + { + return $"{site}:{id.Trim()}".ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs new file mode 100644 index 00000000..f105baab --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs @@ -0,0 +1,105 @@ +using System; +using MongoDB.Driver; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// Manages cached mod data in MongoDB. + internal class ModCacheMongoRepository : BaseCacheRepository, IModCacheRepository + { + /********* + ** Fields + *********/ + /// The collection for cached mod data. + private readonly IMongoCollection Mods; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The authenticated MongoDB database. + public ModCacheMongoRepository(IMongoDatabase database) + { + // get collections + this.Mods = database.GetCollection("mods"); + + // add indexes if needed + this.Mods.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); + } + + + /********* + ** Public methods + *********/ + /// Get the cached mod data. + /// The mod site to search. + /// The mod's unique ID within the . + /// The fetched mod. + /// Whether to update the mod's 'last requested' date. + public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) + { + // get mod + id = this.NormalizeId(id); + mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault(); + if (mod == null) + return false; + + // bump 'last requested' + if (markRequested) + { + mod.LastRequested = DateTimeOffset.UtcNow; + mod = this.SaveMod(mod); + } + + return true; + } + + /// Save data fetched for a mod. + /// The mod site on which the mod is found. + /// The mod's unique ID within the . + /// The mod data. + /// The stored mod record. + public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) + { + id = this.NormalizeId(id); + + cachedMod = this.SaveMod(new CachedMod(site, id, mod)); + } + + /// Delete data for mods which haven't been requested within a given time limit. + /// The minimum age for which to remove mods. + public void RemoveStaleMods(TimeSpan age) + { + DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); + this.Mods.DeleteMany(p => p.LastRequested < minDate); + } + + /// Save data fetched for a mod. + /// The mod data. + public CachedMod SaveMod(CachedMod mod) + { + string id = this.NormalizeId(mod.ID); + + this.Mods.ReplaceOne( + entry => entry.ID == id && entry.Site == mod.Site, + mod, + new ReplaceOptions { IsUpsert = true } + ); + + return mod; + } + + + /********* + ** Private methods + *********/ + /// Normalize a mod ID for case-insensitive search. + /// The mod ID. + public string NormalizeId(string id) + { + return id.Trim().ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs deleted file mode 100644 index 6ba1d73d..00000000 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using MongoDB.Driver; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; - -namespace StardewModdingAPI.Web.Framework.Caching.Mods -{ - /// Encapsulates logic for accessing the mod data cache. - internal class ModCacheRepository : BaseCacheRepository, IModCacheRepository - { - /********* - ** Fields - *********/ - /// The collection for cached mod data. - private readonly IMongoCollection Mods; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The authenticated MongoDB database. - public ModCacheRepository(IMongoDatabase database) - { - // get collections - this.Mods = database.GetCollection("mods"); - - // add indexes if needed - this.Mods.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); - } - - /********* - ** Public methods - *********/ - /// Get the cached mod data. - /// The mod site to search. - /// The mod's unique ID within the . - /// The fetched mod. - /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) - { - // get mod - id = this.NormalizeId(id); - mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault(); - if (mod == null) - return false; - - // bump 'last requested' - if (markRequested) - { - mod.LastRequested = DateTimeOffset.UtcNow; - mod = this.SaveMod(mod); - } - - return true; - } - - /// Save data fetched for a mod. - /// The mod site on which the mod is found. - /// The mod's unique ID within the . - /// The mod data. - /// The stored mod record. - public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) - { - id = this.NormalizeId(id); - - cachedMod = this.SaveMod(new CachedMod(site, id, mod)); - } - - /// Delete data for mods which haven't been requested within a given time limit. - /// The minimum age for which to remove mods. - public void RemoveStaleMods(TimeSpan age) - { - DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); - this.Mods.DeleteMany(p => p.LastRequested < minDate); - } - - - /********* - ** Private methods - *********/ - /// Save data fetched for a mod. - /// The mod data. - public CachedMod SaveMod(CachedMod mod) - { - string id = this.NormalizeId(mod.ID); - - this.Mods.ReplaceOne( - entry => entry.ID == id && entry.Site == mod.Site, - mod, - new ReplaceOptions { IsUpsert = true } - ); - - return mod; - } - - /// Normalize a mod ID for case-insensitive search. - /// The mod ID. - public string NormalizeId(string id) - { - return id.Trim().ToLower(); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index b54c8a2f..02097f52 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -5,7 +5,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki { - /// Encapsulates logic for accessing the wiki data cache. + /// Manages cached wiki data. internal interface IWikiCacheRepository : ICacheRepository { /********* diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs new file mode 100644 index 00000000..4621f5e3 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// Manages cached wiki data in-memory. + internal class WikiCacheMemoryRepository : BaseCacheRepository, IWikiCacheRepository + { + /********* + ** Fields + *********/ + /// The saved wiki metadata. + private CachedWikiMetadata Metadata; + + /// The cached wiki data. + private CachedWikiMod[] Mods = new CachedWikiMod[0]; + + + /********* + ** Public methods + *********/ + /// Get the cached wiki metadata. + /// The fetched metadata. + public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) + { + metadata = this.Metadata; + return metadata != null; + } + + /// Get the cached wiki mods. + /// A filter to apply, if any. + public IEnumerable GetWikiMods(Expression> filter = null) + { + return filter != null + ? this.Mods.Where(filter.Compile()) + : this.Mods.ToArray(); + } + + /// Save data fetched from the wiki compatibility list. + /// The current stable Stardew Valley version. + /// The current beta Stardew Valley version. + /// The mod data. + /// The stored metadata record. + /// The stored mod records. + public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) + { + this.Metadata = cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); + this.Mods = cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs new file mode 100644 index 00000000..07e7c721 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using MongoDB.Driver; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// Manages cached wiki data in MongoDB. + internal class WikiCacheMongoRepository : BaseCacheRepository, IWikiCacheRepository + { + /********* + ** Fields + *********/ + /// The collection for wiki metadata. + private readonly IMongoCollection Metadata; + + /// The collection for wiki mod data. + private readonly IMongoCollection Mods; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The authenticated MongoDB database. + public WikiCacheMongoRepository(IMongoDatabase database) + { + // get collections + this.Metadata = database.GetCollection("wiki-metadata"); + this.Mods = database.GetCollection("wiki-mods"); + + // add indexes if needed + this.Mods.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(p => p.ID))); + } + + /// Get the cached wiki metadata. + /// The fetched metadata. + public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) + { + metadata = this.Metadata.Find("{}").FirstOrDefault(); + return metadata != null; + } + + /// Get the cached wiki mods. + /// A filter to apply, if any. + public IEnumerable GetWikiMods(Expression> filter = null) + { + return filter != null + ? this.Mods.Find(filter).ToList() + : this.Mods.Find("{}").ToList(); + } + + /// Save data fetched from the wiki compatibility list. + /// The current stable Stardew Valley version. + /// The current beta Stardew Valley version. + /// The mod data. + /// The stored metadata record. + /// The stored mod records. + public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) + { + cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); + cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); + + this.Mods.DeleteMany("{}"); + this.Mods.InsertMany(cachedMods); + + this.Metadata.DeleteMany("{}"); + this.Metadata.InsertOne(cachedMetadata); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs deleted file mode 100644 index 1ae9d38f..00000000 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using MongoDB.Driver; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; - -namespace StardewModdingAPI.Web.Framework.Caching.Wiki -{ - /// Encapsulates logic for accessing the wiki data cache. - internal class WikiCacheRepository : BaseCacheRepository, IWikiCacheRepository - { - /********* - ** Fields - *********/ - /// The collection for wiki metadata. - private readonly IMongoCollection WikiMetadata; - - /// The collection for wiki mod data. - private readonly IMongoCollection WikiMods; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The authenticated MongoDB database. - public WikiCacheRepository(IMongoDatabase database) - { - // get collections - this.WikiMetadata = database.GetCollection("wiki-metadata"); - this.WikiMods = database.GetCollection("wiki-mods"); - - // add indexes if needed - this.WikiMods.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(p => p.ID))); - } - - /// Get the cached wiki metadata. - /// The fetched metadata. - public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) - { - metadata = this.WikiMetadata.Find("{}").FirstOrDefault(); - return metadata != null; - } - - /// Get the cached wiki mods. - /// A filter to apply, if any. - public IEnumerable GetWikiMods(Expression> filter = null) - { - return filter != null - ? this.WikiMods.Find(filter).ToList() - : this.WikiMods.Find("{}").ToList(); - } - - /// Save data fetched from the wiki compatibility list. - /// The current stable Stardew Valley version. - /// The current beta Stardew Valley version. - /// The mod data. - /// The stored metadata record. - /// The stored mod records. - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) - { - cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); - cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); - - this.WikiMods.DeleteMany("{}"); - this.WikiMods.InsertMany(cachedMods); - - this.WikiMetadata.DeleteMany("{}"); - this.WikiMetadata.InsertOne(cachedMetadata); - } - } -} diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs deleted file mode 100644 index c7b6cb00..00000000 --- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels -{ - /// The config settings for mod compatibility list. - internal class MongoDbConfig - { - /********* - ** Accessors - *********/ - /// The MongoDB connection string. - public string ConnectionString { get; set; } - - /// The database name. - public string Database { get; set; } - - - /********* - ** Public method - *********/ - /// Get whether a MongoDB instance is configured. - public bool IsConfigured() - { - return !string.IsNullOrWhiteSpace(this.ConnectionString); - } - } -} diff --git a/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs new file mode 100644 index 00000000..61cc4855 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// The config settings for cache storage. + internal class StorageConfig + { + /********* + ** Accessors + *********/ + /// The storage mechanism to use. + public StorageMode Mode { get; set; } + + /// The connection string for the storage mechanism, if applicable. + public string ConnectionString { get; set; } + + /// The database name for the storage mechanism, if applicable. + public string Database { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs b/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs new file mode 100644 index 00000000..4c2ea801 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// Indicates a storage mechanism to use. + internal enum StorageMode + { + /// Store data in a hosted MongoDB instance. + Mongo, + + /// Store data in an in-memory MongoDB instance. This is useful for testing MongoDB storage locally, but will likely fail when deployed since it needs permission to open a local port. + MongoInMemory, + + /// Store data in-memory. This is suitable for local testing or single-instance servers, but will cause issues when distributed across multiple servers. + InMemory + } +} diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 35d22459..ddfae166 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -67,12 +67,13 @@ namespace StardewModdingAPI.Web .Configure(this.Configuration.GetSection("BackgroundServices")) .Configure(this.Configuration.GetSection("ModCompatibilityList")) .Configure(this.Configuration.GetSection("ModUpdateCheck")) - .Configure(this.Configuration.GetSection("MongoDB")) + .Configure(this.Configuration.GetSection("Storage")) .Configure(this.Configuration.GetSection("Site")) .Configure(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddLogging() .AddMemoryCache(); - MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get(); + StorageConfig storageConfig = this.Configuration.GetSection("Storage").Get(); + StorageMode storageMode = storageConfig.Mode; // init MVC services @@ -82,44 +83,66 @@ namespace StardewModdingAPI.Web services .AddRazorPages(); - // init MongoDB - services.AddSingleton(_ => !mongoConfig.IsConfigured() - ? MongoDbRunner.Start() - : throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.") - ); - services.AddSingleton(serv => + // init storage + switch (storageMode) { - // get connection string - string connectionString = mongoConfig.IsConfigured() - ? mongoConfig.ConnectionString - : serv.GetRequiredService().ConnectionString; - - // get client - BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); - return new MongoClient(connectionString).GetDatabase(mongoConfig.Database); - }); - services.AddSingleton(serv => new ModCacheRepository(serv.GetRequiredService())); - services.AddSingleton(serv => new WikiCacheRepository(serv.GetRequiredService())); + case StorageMode.InMemory: + services.AddSingleton(new ModCacheMemoryRepository()); + services.AddSingleton(new WikiCacheMemoryRepository()); + break; + + case StorageMode.Mongo: + case StorageMode.MongoInMemory: + { + // local MongoDB instance + services.AddSingleton(_ => storageMode == StorageMode.MongoInMemory + ? MongoDbRunner.Start() + : throw new NotSupportedException($"The in-memory MongoDB runner isn't available in storage mode {storageMode}.") + ); + + // MongoDB + services.AddSingleton(serv => + { + BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); + return new MongoClient(this.GetMongoDbConnectionString(serv, storageConfig)) + .GetDatabase(storageConfig.Database); + }); + + // repositories + services.AddSingleton(serv => new ModCacheMongoRepository(serv.GetRequiredService())); + services.AddSingleton(serv => new WikiCacheMongoRepository(serv.GetRequiredService())); + } + break; + + default: + throw new NotSupportedException($"Unhandled storage mode '{storageMode}'."); + } // init Hangfire services - .AddHangfire(config => + .AddHangfire((serv, config) => { config .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings(); - if (mongoConfig.IsConfigured()) + switch (storageMode) { - config.UseMongoStorage(MongoClientSettings.FromConnectionString(mongoConfig.ConnectionString), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions - { - MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), - CheckConnection = false // error on startup takes down entire process - }); + case StorageMode.InMemory: + config.UseMemoryStorage(); + break; + + case StorageMode.MongoInMemory: + case StorageMode.Mongo: + string connectionString = this.GetMongoDbConnectionString(serv, storageConfig); + config.UseMongoStorage(MongoClientSettings.FromConnectionString(connectionString), $"{storageConfig.Database}-hangfire", new MongoStorageOptions + { + MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), + CheckConnection = false // error on startup takes down entire process + }); + break; } - else - config.UseMemoryStorage(); }); // init background service @@ -140,6 +163,7 @@ namespace StardewModdingAPI.Web baseUrl: api.ChucklefishBaseUrl, modPageUrlFormat: api.ChucklefishModPageUrlFormat )); + services.AddSingleton(new CurseForgeClient( userAgent: userAgent, apiUrl: api.CurseForgeBaseUrl @@ -229,6 +253,20 @@ namespace StardewModdingAPI.Web settings.NullValueHandling = NullValueHandling.Ignore; } + /// Get the MongoDB connection string for the given storage configuration. + /// The service provider. + /// The storage configuration + /// There's no MongoDB instance in the given storage mode. + private string GetMongoDbConnectionString(IServiceProvider services, StorageConfig storageConfig) + { + return storageConfig.Mode switch + { + StorageMode.Mongo => storageConfig.ConnectionString, + StorageMode.MongoInMemory => services.GetRequiredService().ConnectionString, + _ => throw new NotSupportedException($"There's no MongoDB instance in storage mode {storageConfig.Mode}.") + }; + } + /// Get the redirect rules to apply. private RewriteOptions GetRedirectRules() { diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 54460c46..41c00e79 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -17,7 +17,8 @@ "NexusApiKey": null }, - "MongoDB": { + "Storage": { + "Mode": "MongoInMemory", "ConnectionString": null, "Database": "smapi-edge" }, diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 9cd1efc8..b1d39a6f 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -49,7 +49,8 @@ "PastebinBaseUrl": "https://pastebin.com/" }, - "MongoDB": { + "Storage": { + "Mode": "InMemory", "ConnectionString": null, "Database": "smapi" }, -- cgit From d7add894419543667e60569bfeb439e8e797a4d1 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 23 May 2020 19:25:34 -0400 Subject: drop MongoDB code MongoDB support unnecessarily complicated the code and there's no need to run distributed servers in the foreseeable future. This keeps the abstract storage interface so we can wrap a distributed cache in the future. --- docs/release-notes.md | 2 +- docs/technical/web.md | 22 +- src/SMAPI.Web/BackgroundService.cs | 2 +- src/SMAPI.Web/Controllers/ModsApiController.cs | 26 ++- src/SMAPI.Web/Controllers/ModsController.cs | 9 +- src/SMAPI.Web/Framework/Caching/Cached.cs | 37 ++++ src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs | 107 ---------- .../Framework/Caching/Mods/IModCacheRepository.cs | 5 +- .../Caching/Mods/ModCacheMemoryRepository.cs | 30 +-- .../Caching/Mods/ModCacheMongoRepository.cs | 105 ---------- .../Caching/UtcDateTimeOffsetSerializer.cs | 40 ---- .../Framework/Caching/Wiki/CachedWikiMetadata.cs | 43 ---- .../Framework/Caching/Wiki/CachedWikiMod.cs | 230 --------------------- .../Framework/Caching/Wiki/IWikiCacheRepository.cs | 9 +- .../Caching/Wiki/WikiCacheMemoryRepository.cs | 25 ++- .../Caching/Wiki/WikiCacheMongoRepository.cs | 73 ------- .../Framework/Caching/Wiki/WikiMetadata.cs | 31 +++ .../Framework/ConfigModels/StorageConfig.cs | 18 -- .../Framework/ConfigModels/StorageMode.cs | 15 -- src/SMAPI.Web/SMAPI.Web.csproj | 3 - src/SMAPI.Web/Startup.cs | 78 +------ src/SMAPI.Web/appsettings.Development.json | 6 - 22 files changed, 123 insertions(+), 793 deletions(-) create mode 100644 src/SMAPI.Web/Framework/Caching/Cached.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs delete mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs create mode 100644 src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs delete mode 100644 src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs delete mode 100644 src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs (limited to 'src/SMAPI.Web/Framework/Caching/Mods') diff --git a/docs/release-notes.md b/docs/release-notes.md index 4596a525..f3f2efa4 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -23,7 +23,7 @@ * Fixed `.pdb` files ignored for error stack traces for mods rewritten by SMAPI. * For SMAPI developers: - * When deploying web services to a single-instance app, the MongoDB server can now be replaced with in-memory storage. + * Eliminated MongoDB storage in the web services, which complicated the code unnecessarily. The app still uses an abstract interface for storage, so we can wrap a distributed cache in the future if needed. * Merged the separate legacy redirects app on AWS into the main app on Azure. ## 3.5 diff --git a/docs/technical/web.md b/docs/technical/web.md index ef591aee..d21b87ac 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -352,7 +352,6 @@ your machine, with no external dependencies aside from the actual mod sites. --------------------------- | ----------- `AzureBlobConnectionString` | The connection string for the Azure Blob storage account. Defaults to using the system's temporary file folder if not specified. `GitHubUsername`
`GitHubPassword` | The GitHub credentials with which to query GitHub release info. Defaults to anonymous requests if not specified. - `Storage` | How to storage cached wiki/mod data. `InMemory` is recommended in most cases, or `MongoInMemory` to test the MongoDB storage code. See [production environment](#production-environment) for more info on `Mongo`. 2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site. @@ -385,23 +384,4 @@ Initial setup: `Site:BetaBlurb` | If `Site:BetaEnabled` is true and there's a beta version of SMAPI in its GitHub releases, this is shown on the beta download button as explanatory subtext. `Site:SupporterList` | A list of Patreon supports to credit on the download page. -To enable distributed servers: - -1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)) - for mod data. -2. Add these application settings in the App Services environment: - - property name | description - ------------------------------- | ----------------- - `Storage:Mode` | Set to `Mongo`. - `Storage:ConnectionString` | Set to the connection string for the MongoDB instance. - - Optional settings: - - property name | description - ------------------------------- | ----------------- - `Storage:Database` | Set to the MongoDB database name (defaults to `smapi`). - -To deploy updates: -1. [Deploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure). -2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.) +To deploy updates, just [redeploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure). diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index 275622fe..64bd5ca5 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Web public static async Task UpdateWikiAsync() { WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); - BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods, out _, out _); + BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); } /// Remove mods which haven't been requested in over 48 hours. diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 6032186f..b9d7c32d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -12,6 +12,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; @@ -90,7 +91,7 @@ namespace StardewModdingAPI.Web.Controllers return new ModEntryModel[0]; // fetch wiki data - WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray(); + WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray(); IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { @@ -283,27 +284,30 @@ namespace StardewModdingAPI.Web.Controllers /// Whether to allow non-standard versions. private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions) { - // get mod - if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) + // get from cache + if (this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out Cached cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) + return cachedMod.Data; + + // fetch from mod site { // get site if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); // fetch mod - ModInfoModel result = await repository.GetModInfoAsync(updateKey.ID); - if (result.Error == null) + ModInfoModel mod = await repository.GetModInfoAsync(updateKey.ID); + if (mod.Error == null) { - if (result.Version == null) - result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!SemanticVersion.TryParse(result.Version, allowNonStandardVersions, out _)) - result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."); + if (mod.Version == null) + mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); + else if (!SemanticVersion.TryParse(mod.Version, allowNonStandardVersions, out _)) + mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{mod.Version}'."); } // cache mod - this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, result, out mod); + this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, mod); + return mod; } - return mod.GetModel(); } /// Get update keys based on the available mod metadata, while maintaining the precedence order. diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index b621ded0..24e36709 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; @@ -51,16 +52,16 @@ namespace StardewModdingAPI.Web.Controllers public ModListModel FetchData() { // fetch cached data - if (!this.Cache.TryGetWikiMetadata(out CachedWikiMetadata metadata)) + if (!this.Cache.TryGetWikiMetadata(out Cached metadata)) return new ModListModel(); // build model return new ModListModel( - stableVersion: metadata.StableVersion, - betaVersion: metadata.BetaVersion, + stableVersion: metadata.Data.StableVersion, + betaVersion: metadata.Data.BetaVersion, mods: this.Cache .GetWikiMods() - .Select(mod => new ModModel(mod.GetModel())) + .Select(mod => new ModModel(mod.Data)) .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting lastUpdated: metadata.LastUpdated, isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs new file mode 100644 index 00000000..52041a16 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Cached.cs @@ -0,0 +1,37 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// A cache entry. + /// The cached value type. + internal class Cached + { + /********* + ** Accessors + *********/ + /// The cached data. + public T Data { get; set; } + + /// When the data was last updated. + public DateTimeOffset LastUpdated { get; set; } + + /// When the data was last requested through the mod API. + public DateTimeOffset LastRequested { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public Cached() { } + + /// Construct an instance. + /// The cached data. + public Cached(T data) + { + this.Data = data; + this.LastUpdated = DateTimeOffset.UtcNow; + this.LastRequested = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs deleted file mode 100644 index 96eca847..00000000 --- a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; - -namespace StardewModdingAPI.Web.Framework.Caching.Mods -{ - /// The model for cached mod data. - internal class CachedMod - { - /********* - ** Accessors - *********/ - /**** - ** Tracking - ****/ - /// The internal MongoDB ID. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] - [BsonIgnoreIfDefault] - public ObjectId _id { get; set; } - - /// When the data was last updated. - public DateTimeOffset LastUpdated { get; set; } - - /// When the data was last requested through the web API. - public DateTimeOffset LastRequested { get; set; } - - /**** - ** Metadata - ****/ - /// The mod site on which the mod is found. - public ModRepositoryKey Site { get; set; } - - /// The mod's unique ID within the . - public string ID { get; set; } - - /// The mod availability status on the remote site. - public RemoteModStatus FetchStatus { get; set; } - - /// The error message providing more info for the , if applicable. - public string FetchError { get; set; } - - - /**** - ** Mod info - ****/ - /// The mod's display name. - public string Name { get; set; } - - /// The mod's latest version. - public string MainVersion { get; set; } - - /// The mod's latest optional or prerelease version, if newer than . - public string PreviewVersion { get; set; } - - /// The URL for the mod page. - public string Url { get; set; } - - /// The license URL, if available. - public string LicenseUrl { get; set; } - - /// The license name, if available. - public string LicenseName { get; set; } - - - /********* - ** Accessors - *********/ - /// Construct an instance. - public CachedMod() { } - - /// Construct an instance. - /// The mod site on which the mod is found. - /// The mod's unique ID within the . - /// The mod data. - public CachedMod(ModRepositoryKey site, string id, ModInfoModel mod) - { - // tracking - this.LastUpdated = DateTimeOffset.UtcNow; - this.LastRequested = DateTimeOffset.UtcNow; - - // metadata - this.Site = site; - this.ID = id; - this.FetchStatus = mod.Status; - this.FetchError = mod.Error; - - // mod info - this.Name = mod.Name; - this.MainVersion = mod.Version; - this.PreviewVersion = mod.PreviewVersion; - this.Url = mod.Url; - this.LicenseUrl = mod.LicenseUrl; - this.LicenseName = mod.LicenseName; - } - - /// Get the API model for the cached data. - public ModInfoModel GetModel() - { - return new ModInfoModel(name: this.Name, version: this.MainVersion, url: this.Url, previewVersion: this.PreviewVersion) - .SetLicense(this.LicenseUrl, this.LicenseName) - .SetError(this.FetchStatus, this.FetchError); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 08749f3b..004202f9 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -15,14 +15,13 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true); + bool TryGetMod(ModRepositoryKey site, string id, out Cached mod, bool markRequested = true); /// Save data fetched for a mod. /// The mod site on which the mod is found. /// The mod's unique ID within the . /// The mod data. - /// The stored mod record. - void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod); + void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod); /// Delete data for mods which haven't been requested within a given time limit. /// The minimum age for which to remove mods. diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 9c5a217e..62461116 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods ** Fields *********/ /// The cached mod data indexed by {site key}:{ID}. - private readonly IDictionary Mods = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + private readonly IDictionary> Mods = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); /********* @@ -24,19 +24,20 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) + public bool TryGetMod(ModRepositoryKey site, string id, out Cached mod, bool markRequested = true) { // get mod - if (!this.Mods.TryGetValue(this.GetKey(site, id), out mod)) + if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) + { + mod = null; return false; + } // bump 'last requested' if (markRequested) - { - mod.LastRequested = DateTimeOffset.UtcNow; - mod = this.SaveMod(mod); - } + cachedMod.LastRequested = DateTimeOffset.UtcNow; + mod = cachedMod; return true; } @@ -44,11 +45,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod site on which the mod is found. /// The mod's unique ID within the . /// The mod data. - /// The stored mod record. - public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) + public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod) { string key = this.GetKey(site, id); - cachedMod = this.SaveMod(new CachedMod(site, id, mod)); + this.Mods[key] = new Cached(mod); } /// Delete data for mods which haven't been requested within a given time limit. @@ -66,14 +66,6 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods this.Mods.Remove(key); } - /// Save data fetched for a mod. - /// The mod data. - public CachedMod SaveMod(CachedMod mod) - { - string key = this.GetKey(mod.Site, mod.ID); - return this.Mods[key] = mod; - } - /********* ** Private methods @@ -81,7 +73,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// Get a cache key. /// The mod site. /// The mod ID. - public string GetKey(ModRepositoryKey site, string id) + private string GetKey(ModRepositoryKey site, string id) { return $"{site}:{id.Trim()}".ToLower(); } diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs deleted file mode 100644 index f105baab..00000000 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMongoRepository.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using MongoDB.Driver; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; - -namespace StardewModdingAPI.Web.Framework.Caching.Mods -{ - /// Manages cached mod data in MongoDB. - internal class ModCacheMongoRepository : BaseCacheRepository, IModCacheRepository - { - /********* - ** Fields - *********/ - /// The collection for cached mod data. - private readonly IMongoCollection Mods; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The authenticated MongoDB database. - public ModCacheMongoRepository(IMongoDatabase database) - { - // get collections - this.Mods = database.GetCollection("mods"); - - // add indexes if needed - this.Mods.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); - } - - - /********* - ** Public methods - *********/ - /// Get the cached mod data. - /// The mod site to search. - /// The mod's unique ID within the . - /// The fetched mod. - /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) - { - // get mod - id = this.NormalizeId(id); - mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault(); - if (mod == null) - return false; - - // bump 'last requested' - if (markRequested) - { - mod.LastRequested = DateTimeOffset.UtcNow; - mod = this.SaveMod(mod); - } - - return true; - } - - /// Save data fetched for a mod. - /// The mod site on which the mod is found. - /// The mod's unique ID within the . - /// The mod data. - /// The stored mod record. - public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) - { - id = this.NormalizeId(id); - - cachedMod = this.SaveMod(new CachedMod(site, id, mod)); - } - - /// Delete data for mods which haven't been requested within a given time limit. - /// The minimum age for which to remove mods. - public void RemoveStaleMods(TimeSpan age) - { - DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); - this.Mods.DeleteMany(p => p.LastRequested < minDate); - } - - /// Save data fetched for a mod. - /// The mod data. - public CachedMod SaveMod(CachedMod mod) - { - string id = this.NormalizeId(mod.ID); - - this.Mods.ReplaceOne( - entry => entry.ID == id && entry.Site == mod.Site, - mod, - new ReplaceOptions { IsUpsert = true } - ); - - return mod; - } - - - /********* - ** Private methods - *********/ - /// Normalize a mod ID for case-insensitive search. - /// The mod ID. - public string NormalizeId(string id) - { - return id.Trim().ToLower(); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs deleted file mode 100644 index 6a103e37..00000000 --- a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; - -namespace StardewModdingAPI.Web.Framework.Caching -{ - /// Serializes to a UTC date field instead of the default array. - public class UtcDateTimeOffsetSerializer : StructSerializerBase - { - /********* - ** Fields - *********/ - /// The underlying date serializer. - private static readonly DateTimeSerializer DateTimeSerializer = new DateTimeSerializer(DateTimeKind.Utc, BsonType.DateTime); - - - /********* - ** Public methods - *********/ - /// Deserializes a value. - /// The deserialization context. - /// The deserialization args. - /// A deserialized value. - public override DateTimeOffset Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) - { - DateTime date = UtcDateTimeOffsetSerializer.DateTimeSerializer.Deserialize(context, args); - return new DateTimeOffset(date, TimeSpan.Zero); - } - - /// Serializes a value. - /// The serialization context. - /// The serialization args. - /// The object. - public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, DateTimeOffset value) - { - UtcDateTimeOffsetSerializer.DateTimeSerializer.Serialize(context, args, value.UtcDateTime); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs deleted file mode 100644 index 6a560eb4..00000000 --- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using MongoDB.Bson; - -namespace StardewModdingAPI.Web.Framework.Caching.Wiki -{ - /// The model for cached wiki metadata. - internal class CachedWikiMetadata - { - /********* - ** Accessors - *********/ - /// The internal MongoDB ID. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] - public ObjectId _id { get; set; } - - /// When the data was last updated. - public DateTimeOffset LastUpdated { get; set; } - - /// The current stable Stardew Valley version. - public string StableVersion { get; set; } - - /// The current beta Stardew Valley version. - public string BetaVersion { get; set; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public CachedWikiMetadata() { } - - /// Construct an instance. - /// The current stable Stardew Valley version. - /// The current beta Stardew Valley version. - public CachedWikiMetadata(string stableVersion, string betaVersion) - { - this.StableVersion = stableVersion; - this.BetaVersion = betaVersion; - this.LastUpdated = DateTimeOffset.UtcNow; - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs deleted file mode 100644 index 7e7c99bc..00000000 --- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using MongoDB.Bson.Serialization.Options; -using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; - -namespace StardewModdingAPI.Web.Framework.Caching.Wiki -{ - /// The model for cached wiki mods. - internal class CachedWikiMod - { - /********* - ** Accessors - *********/ - /**** - ** Tracking - ****/ - /// The internal MongoDB ID. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] - public ObjectId _id { get; set; } - - /// When the data was last updated. - public DateTimeOffset LastUpdated { get; set; } - - /**** - ** Mod info - ****/ - /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order. - public string[] ID { get; set; } - - /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. - public string[] Name { get; set; } - - /// The mod's author name. If the author has multiple names, the first one is the most canonical name. - public string[] Author { get; set; } - - /// The mod ID on Nexus. - public int? NexusID { get; set; } - - /// The mod ID in the Chucklefish mod repo. - public int? ChucklefishID { get; set; } - - /// The mod ID in the CurseForge mod repo. - public int? CurseForgeID { get; set; } - - /// The mod key in the CurseForge mod repo (used in mod page URLs). - public string CurseForgeKey { get; set; } - - /// The mod ID in the ModDrop mod repo. - public int? ModDropID { get; set; } - - /// The GitHub repository in the form 'owner/repo'. - public string GitHubRepo { get; set; } - - /// The URL to a non-GitHub source repo. - public string CustomSourceUrl { get; set; } - - /// The custom mod page URL (if applicable). - public string CustomUrl { get; set; } - - /// The name of the mod which loads this content pack, if applicable. - public string ContentPackFor { get; set; } - - /// The human-readable warnings for players about this mod. - public string[] Warnings { get; set; } - - /// The URL of the pull request which submits changes for an unofficial update to the author, if any. - public string PullRequestUrl { get; set; } - - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - public string DevNote { get; set; } - - /// The link anchor for the mod entry in the wiki compatibility list. - public string Anchor { get; set; } - - /**** - ** Stable compatibility - ****/ - /// The compatibility status. - public WikiCompatibilityStatus MainStatus { get; set; } - - /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - public string MainSummary { get; set; } - - /// The game or SMAPI version which broke this mod (if applicable). - public string MainBrokeIn { get; set; } - - /// The version of the latest unofficial update, if applicable. - public string MainUnofficialVersion { get; set; } - - /// The URL to the latest unofficial update, if applicable. - public string MainUnofficialUrl { get; set; } - - /**** - ** Beta compatibility - ****/ - /// The compatibility status. - public WikiCompatibilityStatus? BetaStatus { get; set; } - - /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - public string BetaSummary { get; set; } - - /// The game or SMAPI version which broke this mod (if applicable). - public string BetaBrokeIn { get; set; } - - /// The version of the latest unofficial update, if applicable. - public string BetaUnofficialVersion { get; set; } - - /// The URL to the latest unofficial update, if applicable. - public string BetaUnofficialUrl { get; set; } - - /**** - ** Version maps - ****/ - /// Maps local versions to a semantic version for update checks. - [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] - public IDictionary MapLocalVersions { get; set; } - - /// Maps remote versions to a semantic version for update checks. - [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] - public IDictionary MapRemoteVersions { get; set; } - - - /********* - ** Accessors - *********/ - /// Construct an instance. - public CachedWikiMod() { } - - /// Construct an instance. - /// The mod data. - public CachedWikiMod(WikiModEntry mod) - { - // tracking - this.LastUpdated = DateTimeOffset.UtcNow; - - // mod info - this.ID = mod.ID; - this.Name = mod.Name; - this.Author = mod.Author; - this.NexusID = mod.NexusID; - this.ChucklefishID = mod.ChucklefishID; - this.CurseForgeID = mod.CurseForgeID; - this.CurseForgeKey = mod.CurseForgeKey; - this.ModDropID = mod.ModDropID; - this.GitHubRepo = mod.GitHubRepo; - this.CustomSourceUrl = mod.CustomSourceUrl; - this.CustomUrl = mod.CustomUrl; - this.ContentPackFor = mod.ContentPackFor; - this.PullRequestUrl = mod.PullRequestUrl; - this.Warnings = mod.Warnings; - this.DevNote = mod.DevNote; - this.Anchor = mod.Anchor; - - // stable compatibility - this.MainStatus = mod.Compatibility.Status; - this.MainSummary = mod.Compatibility.Summary; - this.MainBrokeIn = mod.Compatibility.BrokeIn; - this.MainUnofficialVersion = mod.Compatibility.UnofficialVersion?.ToString(); - this.MainUnofficialUrl = mod.Compatibility.UnofficialUrl; - - // beta compatibility - this.BetaStatus = mod.BetaCompatibility?.Status; - this.BetaSummary = mod.BetaCompatibility?.Summary; - this.BetaBrokeIn = mod.BetaCompatibility?.BrokeIn; - this.BetaUnofficialVersion = mod.BetaCompatibility?.UnofficialVersion?.ToString(); - this.BetaUnofficialUrl = mod.BetaCompatibility?.UnofficialUrl; - - // version maps - this.MapLocalVersions = mod.MapLocalVersions; - this.MapRemoteVersions = mod.MapRemoteVersions; - } - - /// Reconstruct the original model. - public WikiModEntry GetModel() - { - var mod = new WikiModEntry - { - ID = this.ID, - Name = this.Name, - Author = this.Author, - NexusID = this.NexusID, - ChucklefishID = this.ChucklefishID, - CurseForgeID = this.CurseForgeID, - CurseForgeKey = this.CurseForgeKey, - ModDropID = this.ModDropID, - GitHubRepo = this.GitHubRepo, - CustomSourceUrl = this.CustomSourceUrl, - CustomUrl = this.CustomUrl, - ContentPackFor = this.ContentPackFor, - Warnings = this.Warnings, - PullRequestUrl = this.PullRequestUrl, - DevNote = this.DevNote, - Anchor = this.Anchor, - - // stable compatibility - Compatibility = new WikiCompatibilityInfo - { - Status = this.MainStatus, - Summary = this.MainSummary, - BrokeIn = this.MainBrokeIn, - UnofficialVersion = this.MainUnofficialVersion != null ? new SemanticVersion(this.MainUnofficialVersion) : null, - UnofficialUrl = this.MainUnofficialUrl - }, - - // version maps - MapLocalVersions = this.MapLocalVersions, - MapRemoteVersions = this.MapRemoteVersions - }; - - // beta compatibility - if (this.BetaStatus != null) - { - mod.BetaCompatibility = new WikiCompatibilityInfo - { - Status = this.BetaStatus.Value, - Summary = this.BetaSummary, - BrokeIn = this.BetaBrokeIn, - UnofficialVersion = this.BetaUnofficialVersion != null ? new SemanticVersion(this.BetaUnofficialVersion) : null, - UnofficialUrl = this.BetaUnofficialUrl - }; - } - - return mod; - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index 02097f52..2ab7ea5a 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq.Expressions; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki @@ -13,18 +12,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// Get the cached wiki metadata. /// The fetched metadata. - bool TryGetWikiMetadata(out CachedWikiMetadata metadata); + bool TryGetWikiMetadata(out Cached metadata); /// Get the cached wiki mods. /// A filter to apply, if any. - IEnumerable GetWikiMods(Expression> filter = null); + IEnumerable> GetWikiMods(Func filter = null); /// Save data fetched from the wiki compatibility list. /// The current stable Stardew Valley version. /// The current beta Stardew Valley version. /// The mod data. - /// The stored metadata record. - /// The stored mod records. - void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods); + void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods); } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs index 4621f5e3..064a7c3c 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki @@ -13,10 +12,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Fields *********/ /// The saved wiki metadata. - private CachedWikiMetadata Metadata; + private Cached Metadata; /// The cached wiki data. - private CachedWikiMod[] Mods = new CachedWikiMod[0]; + private Cached[] Mods = new Cached[0]; /********* @@ -24,7 +23,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// Get the cached wiki metadata. /// The fetched metadata. - public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) + public bool TryGetWikiMetadata(out Cached metadata) { metadata = this.Metadata; return metadata != null; @@ -32,23 +31,23 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// Get the cached wiki mods. /// A filter to apply, if any. - public IEnumerable GetWikiMods(Expression> filter = null) + public IEnumerable> GetWikiMods(Func filter = null) { - return filter != null - ? this.Mods.Where(filter.Compile()) - : this.Mods.ToArray(); + foreach (var mod in this.Mods) + { + if (filter == null || filter(mod.Data)) + yield return mod; + } } /// Save data fetched from the wiki compatibility list. /// The current stable Stardew Valley version. /// The current beta Stardew Valley version. /// The mod data. - /// The stored metadata record. - /// The stored mod records. - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) + public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods) { - this.Metadata = cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); - this.Mods = cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); + this.Metadata = new Cached(new WikiMetadata(stableVersion, betaVersion)); + this.Mods = mods.Select(mod => new Cached(mod)).ToArray(); } } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs deleted file mode 100644 index 07e7c721..00000000 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMongoRepository.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using MongoDB.Driver; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; - -namespace StardewModdingAPI.Web.Framework.Caching.Wiki -{ - /// Manages cached wiki data in MongoDB. - internal class WikiCacheMongoRepository : BaseCacheRepository, IWikiCacheRepository - { - /********* - ** Fields - *********/ - /// The collection for wiki metadata. - private readonly IMongoCollection Metadata; - - /// The collection for wiki mod data. - private readonly IMongoCollection Mods; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The authenticated MongoDB database. - public WikiCacheMongoRepository(IMongoDatabase database) - { - // get collections - this.Metadata = database.GetCollection("wiki-metadata"); - this.Mods = database.GetCollection("wiki-mods"); - - // add indexes if needed - this.Mods.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(p => p.ID))); - } - - /// Get the cached wiki metadata. - /// The fetched metadata. - public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) - { - metadata = this.Metadata.Find("{}").FirstOrDefault(); - return metadata != null; - } - - /// Get the cached wiki mods. - /// A filter to apply, if any. - public IEnumerable GetWikiMods(Expression> filter = null) - { - return filter != null - ? this.Mods.Find(filter).ToList() - : this.Mods.Find("{}").ToList(); - } - - /// Save data fetched from the wiki compatibility list. - /// The current stable Stardew Valley version. - /// The current beta Stardew Valley version. - /// The mod data. - /// The stored metadata record. - /// The stored mod records. - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) - { - cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); - cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); - - this.Mods.DeleteMany("{}"); - this.Mods.InsertMany(cachedMods); - - this.Metadata.DeleteMany("{}"); - this.Metadata.InsertOne(cachedMetadata); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs new file mode 100644 index 00000000..c04de4a5 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs @@ -0,0 +1,31 @@ +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// The model for cached wiki metadata. + internal class WikiMetadata + { + /********* + ** Accessors + *********/ + /// The current stable Stardew Valley version. + public string StableVersion { get; set; } + + /// The current beta Stardew Valley version. + public string BetaVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public WikiMetadata() { } + + /// Construct an instance. + /// The current stable Stardew Valley version. + /// The current beta Stardew Valley version. + public WikiMetadata(string stableVersion, string betaVersion) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs deleted file mode 100644 index 61cc4855..00000000 --- a/src/SMAPI.Web/Framework/ConfigModels/StorageConfig.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels -{ - /// The config settings for cache storage. - internal class StorageConfig - { - /********* - ** Accessors - *********/ - /// The storage mechanism to use. - public StorageMode Mode { get; set; } - - /// The connection string for the storage mechanism, if applicable. - public string ConnectionString { get; set; } - - /// The database name for the storage mechanism, if applicable. - public string Database { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs b/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs deleted file mode 100644 index 4c2ea801..00000000 --- a/src/SMAPI.Web/Framework/ConfigModels/StorageMode.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels -{ - /// Indicates a storage mechanism to use. - internal enum StorageMode - { - /// Store data in a hosted MongoDB instance. - Mongo, - - /// Store data in an in-memory MongoDB instance. This is useful for testing MongoDB storage locally, but will likely fail when deployed since it needs permission to open a local port. - MongoInMemory, - - /// Store data in-memory. This is suitable for local testing or single-instance servers, but will cause issues when distributed across multiple servers. - InMemory - } -} diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 7ed79ea3..c6c0f774 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -15,14 +15,11 @@ - - - diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index dee2edc2..586b0c3c 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Generic; using System.Net; using Hangfire; using Hangfire.MemoryStorage; -using Hangfire.Mongo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Rewrite; @@ -11,13 +9,9 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Mongo2Go; -using MongoDB.Bson.Serialization; -using MongoDB.Driver; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Web.Framework; -using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; @@ -68,13 +62,10 @@ namespace StardewModdingAPI.Web .Configure(this.Configuration.GetSection("BackgroundServices")) .Configure(this.Configuration.GetSection("ModCompatibilityList")) .Configure(this.Configuration.GetSection("ModUpdateCheck")) - .Configure(this.Configuration.GetSection("Storage")) .Configure(this.Configuration.GetSection("Site")) .Configure(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddLogging() .AddMemoryCache(); - StorageConfig storageConfig = this.Configuration.GetSection("Storage").Get(); - StorageMode storageMode = storageConfig.Mode; // init MVC services @@ -85,39 +76,8 @@ namespace StardewModdingAPI.Web .AddRazorPages(); // init storage - switch (storageMode) - { - case StorageMode.InMemory: - services.AddSingleton(new ModCacheMemoryRepository()); - services.AddSingleton(new WikiCacheMemoryRepository()); - break; - - case StorageMode.Mongo: - case StorageMode.MongoInMemory: - { - // local MongoDB instance - services.AddSingleton(_ => storageMode == StorageMode.MongoInMemory - ? MongoDbRunner.Start() - : throw new NotSupportedException($"The in-memory MongoDB runner isn't available in storage mode {storageMode}.") - ); - - // MongoDB - services.AddSingleton(serv => - { - BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); - return new MongoClient(this.GetMongoDbConnectionString(serv, storageConfig)) - .GetDatabase(storageConfig.Database); - }); - - // repositories - services.AddSingleton(serv => new ModCacheMongoRepository(serv.GetRequiredService())); - services.AddSingleton(serv => new WikiCacheMongoRepository(serv.GetRequiredService())); - } - break; - - default: - throw new NotSupportedException($"Unhandled storage mode '{storageMode}'."); - } + services.AddSingleton(new ModCacheMemoryRepository()); + services.AddSingleton(new WikiCacheMemoryRepository()); // init Hangfire services @@ -126,24 +86,8 @@ namespace StardewModdingAPI.Web config .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings(); - - switch (storageMode) - { - case StorageMode.InMemory: - config.UseMemoryStorage(); - break; - - case StorageMode.MongoInMemory: - case StorageMode.Mongo: - string connectionString = this.GetMongoDbConnectionString(serv, storageConfig); - config.UseMongoStorage(MongoClientSettings.FromConnectionString(connectionString), $"{storageConfig.Database}-hangfire", new MongoStorageOptions - { - MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), - CheckConnection = false // error on startup takes down entire process - }); - break; - } + .UseRecommendedSerializerSettings() + .UseMemoryStorage(); }); // init background service @@ -254,20 +198,6 @@ namespace StardewModdingAPI.Web settings.NullValueHandling = NullValueHandling.Ignore; } - /// Get the MongoDB connection string for the given storage configuration. - /// The service provider. - /// The storage configuration - /// There's no MongoDB instance in the given storage mode. - private string GetMongoDbConnectionString(IServiceProvider services, StorageConfig storageConfig) - { - return storageConfig.Mode switch - { - StorageMode.Mongo => storageConfig.ConnectionString, - StorageMode.MongoInMemory => services.GetRequiredService().ConnectionString, - _ => throw new NotSupportedException($"There's no MongoDB instance in storage mode {storageConfig.Mode}.") - }; - } - /// Get the redirect rules to apply. private RewriteOptions GetRedirectRules() { diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 41c00e79..3aa69285 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -17,12 +17,6 @@ "NexusApiKey": null }, - "Storage": { - "Mode": "MongoInMemory", - "ConnectionString": null, - "Database": "smapi-edge" - }, - "BackgroundServices": { "Enabled": true } -- cgit From 786077340f2cea37d82455fc413535ae82a912ee Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 23 May 2020 21:55:11 -0400 Subject: refactor update check API This simplifies the logic for individual clients, centralises common logic, and prepares for upcoming features. --- docs/release-notes.md | 1 + .../Framework/UpdateData/ModRepositoryKey.cs | 24 --- .../Framework/UpdateData/ModSiteKey.cs | 24 +++ .../Framework/UpdateData/UpdateKey.cs | 67 ++++---- src/SMAPI.Web/Controllers/ModsApiController.cs | 146 ++++------------- .../Framework/Caching/Mods/IModCacheRepository.cs | 6 +- .../Caching/Mods/ModCacheMemoryRepository.cs | 12 +- .../Clients/Chucklefish/ChucklefishClient.cs | 38 +++-- .../Clients/Chucklefish/ChucklefishMod.cs | 18 --- .../Clients/Chucklefish/IChucklefishClient.cs | 12 +- .../Clients/CurseForge/CurseForgeClient.cs | 72 ++++----- .../Framework/Clients/CurseForge/CurseForgeMod.cs | 23 --- .../Clients/CurseForge/ICurseForgeClient.cs | 12 +- .../Framework/Clients/GenericModDownload.cs | 36 +++++ src/SMAPI.Web/Framework/Clients/GenericModPage.cs | 79 +++++++++ .../Framework/Clients/GitHub/GitHubClient.cs | 56 +++++++ .../Framework/Clients/GitHub/IGitHubClient.cs | 2 +- src/SMAPI.Web/Framework/Clients/IModSiteClient.cs | 23 +++ .../Framework/Clients/ModDrop/IModDropClient.cs | 12 +- .../Framework/Clients/ModDrop/ModDropClient.cs | 63 ++++---- .../Framework/Clients/ModDrop/ModDropMod.cs | 21 --- .../ModDrop/ResponseModels/FileDataModel.cs | 16 +- .../Framework/Clients/Nexus/INexusClient.cs | 12 +- .../Framework/Clients/Nexus/NexusClient.cs | 94 ++++++----- src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs | 32 ---- .../Clients/Nexus/ResponseModels/NexusMod.cs | 33 ++++ src/SMAPI.Web/Framework/Extensions.cs | 6 + src/SMAPI.Web/Framework/IModDownload.cs | 15 ++ src/SMAPI.Web/Framework/IModPage.cs | 52 ++++++ src/SMAPI.Web/Framework/ModInfoModel.cs | 81 ++++++++++ .../Framework/ModRepositories/BaseRepository.cs | 51 ------ .../ModRepositories/ChucklefishRepository.cs | 57 ------- .../ModRepositories/CurseForgeRepository.cs | 63 -------- .../Framework/ModRepositories/GitHubRepository.cs | 82 ---------- .../Framework/ModRepositories/IModRepository.cs | 24 --- .../Framework/ModRepositories/ModDropRepository.cs | 57 ------- .../Framework/ModRepositories/ModInfoModel.cs | 96 ----------- .../Framework/ModRepositories/NexusRepository.cs | 65 -------- .../Framework/ModRepositories/RemoteModStatus.cs | 18 --- src/SMAPI.Web/Framework/ModSiteManager.cs | 180 +++++++++++++++++++++ src/SMAPI.Web/Framework/RemoteModStatus.cs | 18 +++ 41 files changed, 825 insertions(+), 974 deletions(-) delete mode 100644 src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs create mode 100644 src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs create mode 100644 src/SMAPI.Web/Framework/Clients/GenericModDownload.cs create mode 100644 src/SMAPI.Web/Framework/Clients/GenericModPage.cs create mode 100644 src/SMAPI.Web/Framework/Clients/IModSiteClient.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs create mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs create mode 100644 src/SMAPI.Web/Framework/IModDownload.cs create mode 100644 src/SMAPI.Web/Framework/IModPage.cs create mode 100644 src/SMAPI.Web/Framework/ModInfoModel.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs create mode 100644 src/SMAPI.Web/Framework/ModSiteManager.cs create mode 100644 src/SMAPI.Web/Framework/RemoteModStatus.cs (limited to 'src/SMAPI.Web/Framework/Caching/Mods') diff --git a/docs/release-notes.md b/docs/release-notes.md index f3f2efa4..894fd562 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -24,6 +24,7 @@ * For SMAPI developers: * Eliminated MongoDB storage in the web services, which complicated the code unnecessarily. The app still uses an abstract interface for storage, so we can wrap a distributed cache in the future if needed. + * Overhauled update checks to simplify individual clients, centralize common logic, and enable upcoming features. * Merged the separate legacy redirects app on AWS into the main app on Azure. ## 3.5 diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs deleted file mode 100644 index 765ca334..00000000 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace StardewModdingAPI.Toolkit.Framework.UpdateData -{ - /// A mod repository which SMAPI can check for updates. - public enum ModRepositoryKey - { - /// An unknown or invalid mod repository. - Unknown, - - /// The Chucklefish mod repository. - Chucklefish, - - /// The CurseForge mod repository. - CurseForge, - - /// A GitHub project containing releases. - GitHub, - - /// The ModDrop mod repository. - ModDrop, - - /// The Nexus Mods mod repository. - Nexus - } -} diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs new file mode 100644 index 00000000..47cd3f7e --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Toolkit.Framework.UpdateData +{ + /// A mod site which SMAPI can check for updates. + public enum ModSiteKey + { + /// An unknown or invalid mod repository. + Unknown, + + /// The Chucklefish mod repository. + Chucklefish, + + /// The CurseForge mod repository. + CurseForge, + + /// A GitHub project containing releases. + GitHub, + + /// The ModDrop mod repository. + ModDrop, + + /// The Nexus Mods mod repository. + Nexus + } +} diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 3fc1759e..f6044148 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -11,8 +11,8 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// The raw update key text. public string RawText { get; } - /// The mod repository containing the mod. - public ModRepositoryKey Repository { get; } + /// The mod site containing the mod. + public ModSiteKey Site { get; } /// The mod ID within the repository. public string ID { get; } @@ -26,53 +26,56 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData *********/ /// Construct an instance. /// The raw update key text. - /// The mod repository containing the mod. - /// The mod ID within the repository. - public UpdateKey(string rawText, ModRepositoryKey repository, string id) + /// The mod site containing the mod. + /// The mod ID within the site. + public UpdateKey(string rawText, ModSiteKey site, string id) { - this.RawText = rawText; - this.Repository = repository; - this.ID = id; + this.RawText = rawText?.Trim(); + this.Site = site; + this.ID = id?.Trim(); this.LooksValid = - repository != ModRepositoryKey.Unknown + site != ModSiteKey.Unknown && !string.IsNullOrWhiteSpace(id); } /// Construct an instance. - /// The mod repository containing the mod. - /// The mod ID within the repository. - public UpdateKey(ModRepositoryKey repository, string id) - : this($"{repository}:{id}", repository, id) { } + /// The mod site containing the mod. + /// The mod ID within the site. + public UpdateKey(ModSiteKey site, string id) + : this(UpdateKey.GetString(site, id), site, id) { } /// Parse a raw update key. /// The raw update key to parse. public static UpdateKey Parse(string raw) { - // split parts - string[] parts = raw?.Split(':'); - if (parts == null || parts.Length != 2) - return new UpdateKey(raw, ModRepositoryKey.Unknown, null); - - // extract parts - string repositoryKey = parts[0].Trim(); - string id = parts[1].Trim(); + // extract site + ID + string rawSite; + string id; + { + string[] parts = raw?.Trim().Split(':'); + if (parts == null || parts.Length != 2) + return new UpdateKey(raw, ModSiteKey.Unknown, null); + + rawSite = parts[0].Trim(); + id = parts[1].Trim(); + } if (string.IsNullOrWhiteSpace(id)) id = null; // parse - if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository)) - return new UpdateKey(raw, ModRepositoryKey.Unknown, id); + if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) + return new UpdateKey(raw, ModSiteKey.Unknown, id); if (id == null) - return new UpdateKey(raw, repository, null); + return new UpdateKey(raw, site, null); - return new UpdateKey(raw, repository, id); + return new UpdateKey(raw, site, id); } /// Get a string that represents the current object. public override string ToString() { return this.LooksValid - ? $"{this.Repository}:{this.ID}" + ? UpdateKey.GetString(this.Site, this.ID) : this.RawText; } @@ -82,7 +85,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData { return other != null - && this.Repository == other.Repository + && this.Site == other.Site && string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase); } @@ -97,7 +100,15 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// A hash code for the current object. public override int GetHashCode() { - return $"{this.Repository}:{this.ID}".ToLower().GetHashCode(); + return $"{this.Site}:{this.ID}".ToLower().GetHashCode(); + } + + /// Get the string representation of an update key. + /// The mod site containing the mod. + /// The mod ID within the repository. + public static string GetString(ModSiteKey site, string id) + { + return $"{site}:{id}".Trim(); } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index b9d7c32d..14be520d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -15,13 +15,13 @@ using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; +using StardewModdingAPI.Web.Framework.Clients; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; 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.ConfigModels; -using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Controllers { @@ -33,8 +33,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// The mod repositories which provide mod metadata. - private readonly IDictionary Repositories; + /// The mod sites which provide mod metadata. + private readonly ModSiteManager ModSites; /// The cache in which to store wiki data. private readonly IWikiCacheRepository WikiCache; @@ -69,16 +69,7 @@ namespace StardewModdingAPI.Web.Controllers this.WikiCache = wikiCache; this.ModCache = modCache; this.Config = config; - this.Repositories = - new IModRepository[] - { - new ChucklefishRepository(chucklefish), - new CurseForgeRepository(curseForge), - new GitHubRepository(github), - new ModDropRepository(modDrop), - new NexusRepository(nexus) - } - .ToDictionary(p => p.VendorKey); + this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus }); } /// Fetch version metadata for the given mods. @@ -149,40 +140,18 @@ namespace StardewModdingAPI.Web.Controllers } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions); - if (data.Error != null) + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions); + if (data.Status != RemoteModStatus.Ok) { - errors.Add(data.Error); + errors.Add(data.Error ?? data.Status.ToString()); continue; } - // handle main version - if (data.Version != null) - { - ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); - if (version == null) - { - errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); - continue; - } - - if (this.IsNewer(version, main?.Version)) - main = new ModEntryVersionModel(version, data.Url); - } - - // handle optional version - if (data.PreviewVersion != null) - { - ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); - if (version == null) - { - errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); - continue; - } - - if (this.IsNewer(version, optional?.Version)) - optional = new ModEntryVersionModel(version, data.Url); - } + // handle versions + if (this.IsNewer(data.Version, main?.Version)) + main = new ModEntryVersionModel(data.Version, data.Url); + if (this.IsNewer(data.PreviewVersion, optional?.Version)) + optional = new ModEntryVersionModel(data.PreviewVersion, data.Url); } // get unofficial version @@ -222,7 +191,7 @@ namespace StardewModdingAPI.Web.Controllers } // get recommended update (if any) - ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -282,32 +251,27 @@ namespace StardewModdingAPI.Web.Controllers /// Get the mod info for an update key. /// The namespaced update key. /// Whether to allow non-standard versions. - private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions) + /// Maps remote versions to a semantic version for update checks. + private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, IDictionary mapRemoteVersions) { - // get from cache - if (this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out Cached cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) - return cachedMod.Data; - - // fetch from mod site + // get mod page + IModPage page; { - // get site - if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + bool isCached = + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached cachedMod) + && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); - // fetch mod - ModInfoModel mod = await repository.GetModInfoAsync(updateKey.ID); - if (mod.Error == null) + if (isCached) + page = cachedMod.Data; + else { - if (mod.Version == null) - mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!SemanticVersion.TryParse(mod.Version, allowNonStandardVersions, out _)) - mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{mod.Version}'."); + page = await this.ModSites.GetModPageAsync(updateKey); + this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); } - - // cache mod - this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, mod); - return mod; } + + // get version info + return this.ModSites.GetPageVersions(page, allowNonStandardVersions, mapRemoteVersions); } /// Get update keys based on the available mod metadata, while maintaining the precedence order. @@ -334,13 +298,13 @@ namespace StardewModdingAPI.Web.Controllers if (entry != null) { if (entry.NexusID.HasValue) - yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}"; + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID?.ToString()); if (entry.ModDropID.HasValue) - yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}"; + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID?.ToString()); if (entry.CurseForgeID.HasValue) - yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}"; + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID?.ToString()); if (entry.ChucklefishID.HasValue) - yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}"; + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID?.ToString()); } } @@ -355,51 +319,5 @@ namespace StardewModdingAPI.Web.Controllers yield return key; } } - - /// Get a semantic local version for update checks. - /// The version to parse. - /// A map of version replacements. - /// Whether to allow non-standard versions. - private ISemanticVersion GetMappedVersion(string version, IDictionary map, bool allowNonStandard) - { - // try mapped version - string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); - if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) - return parsedNew; - - // return original version - return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) - ? parsedOld - : null; - } - - /// Get a semantic local version for update checks. - /// The version to map. - /// A map of version replacements. - /// Whether to allow non-standard versions. - private string GetRawMappedVersion(string version, IDictionary map, bool allowNonStandard) - { - if (version == null || map == null || !map.Any()) - return version; - - // match exact raw version - if (map.ContainsKey(version)) - return map[version]; - - // match parsed version - if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) - { - if (map.ContainsKey(parsed.ToString())) - return map[parsed.ToString()]; - - foreach ((string fromRaw, string toRaw) in map) - { - if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) - return newVersion.ToString(); - } - } - - return version; - } } } diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 004202f9..0d912c7b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -1,6 +1,6 @@ using System; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -15,13 +15,13 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - bool TryGetMod(ModRepositoryKey site, string id, out Cached mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, out Cached mod, bool markRequested = true); /// Save data fetched for a mod. /// The mod site on which the mod is found. /// The mod's unique ID within the . /// The mod data. - void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod); + void SaveMod(ModSiteKey site, string id, IModPage mod); /// Delete data for mods which haven't been requested within a given time limit. /// The minimum age for which to remove mods. diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 62461116..6b0ec1ec 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods ** Fields *********/ /// The cached mod data indexed by {site key}:{ID}. - private readonly IDictionary> Mods = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); + private readonly IDictionary> Mods = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); /********* @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModRepositoryKey site, string id, out Cached mod, bool markRequested = true) + public bool TryGetMod(ModSiteKey site, string id, out Cached mod, bool markRequested = true) { // get mod if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) @@ -45,10 +45,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod site on which the mod is found. /// The mod's unique ID within the . /// The mod data. - public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod) + public void SaveMod(ModSiteKey site, string id, IModPage mod) { string key = this.GetKey(site, id); - this.Mods[key] = new Cached(mod); + this.Mods[key] = new Cached(mod); } /// Delete data for mods which haven't been requested within a given time limit. @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// Get a cache key. /// The mod site. /// The mod ID. - private string GetKey(ModRepositoryKey site, string id) + private string GetKey(ModSiteKey site, string id) { return $"{site}:{id.Trim()}".ToLower(); } diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index cdb281e2..ca156da4 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish { @@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish private readonly IClient Client; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.Chucklefish; + + /********* ** Public methods *********/ @@ -32,42 +40,40 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); } - /// Get metadata about a mod. - /// The Chucklefish mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(uint id) + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + // get mod ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); + // fetch HTML string html; try { html = await this.Client - .GetAsync(string.Format(this.ModPageUrlFormat, id)) + .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) .AsString(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) { - return null; + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } - - // parse HTML var doc = new HtmlDocument(); doc.LoadHtml(html); // extract mod info - string url = this.GetModUrl(id); + string url = this.GetModUrl(parsedId); string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value; if (name.StartsWith("[SMAPI] ")) name = name.Substring("[SMAPI] ".Length); string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; - // create model - return new ChucklefishMod - { - Name = name, - Version = version, - Url = url - }; + // return info + return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty()); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs deleted file mode 100644 index fd0101d4..00000000 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish -{ - /// Mod metadata from the Chucklefish mod site. - internal class ChucklefishMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's semantic version number. - public string Version { get; set; } - - /// The mod's web URL. - public string Url { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs index 1d8b256e..836d43f7 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish { /// An HTTP client for fetching mod metadata from the Chucklefish mod site. - internal interface IChucklefishClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The Chucklefish mod ID. - /// Returns the mod info if found, else null. - Task GetModAsync(uint id); - } + internal interface IChucklefishClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index a6fd21fd..d8008721 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -1,8 +1,8 @@ -using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading.Tasks; using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; namespace StardewModdingAPI.Web.Framework.Clients.CurseForge @@ -20,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.CurseForge; + + /********* ** Public methods *********/ @@ -31,59 +38,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); } - /// Get metadata about a mod. - /// The CurseForge mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(long id) + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + // get ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); + // get raw data ModModel mod = await this.Client - .GetAsync($"addon/{id}") + .GetAsync($"addon/{parsedId}") .As(); if (mod == null) - return null; + return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - // get latest versions - string invalidVersion = null; - ISemanticVersion latest = null; + // get downloads + List downloads = new List(); foreach (ModFileModel file in mod.LatestFiles) { - // extract version - ISemanticVersion version; - { - string raw = this.GetRawVersion(file); - if (raw == null) - continue; - - if (!SemanticVersion.TryParse(raw, out version)) - { - invalidVersion ??= raw; - continue; - } - } - - // track latest version - if (latest == null || version.IsNewerThan(latest)) - latest = version; - } - - // get error - string error = null; - if (latest == null && invalidVersion == null) - { - error = mod.LatestFiles.Any() - ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format." - : $"CurseForge mod {id} has no downloads."; + downloads.Add( + new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file)) + ); } - // generate result - return new CurseForgeMod - { - Name = mod.Name, - LatestVersion = latest?.ToString() ?? invalidVersion, - Url = mod.WebsiteUrl, - Error = error - }; + // return info + return page.SetInfo(name: mod.Name, version: null, url: mod.WebsiteUrl, downloads: downloads); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs deleted file mode 100644 index e5bb8cf1..00000000 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Newtonsoft.Json; - -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge -{ - /// Mod metadata from the CurseForge API. - internal class CurseForgeMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The latest file version. - public string LatestVersion { get; set; } - - /// The mod's web URL. - public string Url { get; set; } - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - public string Error { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs index 907b4087..2018c230 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.CurseForge { /// An HTTP client for fetching mod metadata from the CurseForge API. - internal interface ICurseForgeClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The CurseForge mod ID. - /// Returns the mod info if found, else null. - Task GetModAsync(long id); - } + internal interface ICurseForgeClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs new file mode 100644 index 00000000..f08b471c --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -0,0 +1,36 @@ +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// Generic metadata about a file download on a mod page. + internal class GenericModDownload : IModDownload + { + /********* + ** Accessors + *********/ + /// The download's display name. + public string Name { get; set; } + + /// The download's description. + public string Description { get; set; } + + /// The download's file version. + public string Version { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public GenericModDownload() { } + + /// Construct an instance. + /// The download's display name. + /// The download's description. + /// The download's file version. + public GenericModDownload(string name, string description, string version) + { + this.Name = name; + this.Description = description; + this.Version = version; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs new file mode 100644 index 00000000..622e6c56 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// Generic metadata about a mod page. + internal class GenericModPage : IModPage + { + /********* + ** Accessors + *********/ + /// The mod site containing the mod. + public ModSiteKey Site { get; set; } + + /// The mod's unique ID within the site. + public string Id { get; set; } + + /// The mod name. + public string Name { get; set; } + + /// The mod's semantic version number. + public string Version { get; set; } + + /// The mod's web URL. + public string Url { get; set; } + + /// The mod downloads. + public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + + /// The mod availability status on the remote site. + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public GenericModPage() { } + + /// Construct an instance. + /// The mod site containing the mod. + /// The mod's unique ID within the site. + public GenericModPage(ModSiteKey site, string id) + { + this.Site = site; + this.Id = id; + } + + /// Set the fetched mod info. + /// The mod name. + /// The mod's semantic version number. + /// The mod's web URL. + /// The mod downloads. + public IModPage SetInfo(string name, string version, string url, IEnumerable downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Downloads = downloads.ToArray(); + + return this; + } + + /// Set a mod fetch error. + /// The mod availability status on the remote site. + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + public IModPage SetError(RemoteModStatus status, string error) + { + this.Status = status; + this.Error = error; + + return this; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 84c20957..2f1eb854 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Clients.GitHub { @@ -16,6 +17,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub private readonly IClient Client; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.GitHub; + + /********* ** Public methods *********/ @@ -79,6 +87,54 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub } } + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) + { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); + + // fetch repo info + GitRepo repository = await this.GetRepositoryAsync(id); + if (repository == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); + string name = repository.FullName; + string url = $"{repository.WebUrl}/releases"; + + // get releases + GitRelease latest; + GitRelease preview; + { + // get latest release (whether preview or stable) + latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); + if (latest == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); + + // get stable version if different + preview = null; + if (latest.IsPrerelease) + { + GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + if (release != null) + { + preview = latest; + latest = release; + } + } + } + + // get downloads + IModDownload[] downloads = new[] { latest, preview } + .Where(release => release != null) + .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag)) + .ToArray(); + + // return info + return page.SetInfo(name: name, url: url, version: null, downloads: downloads); + } + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index a34f03bd..0d6f4643 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.GitHub { /// An HTTP client for fetching metadata from GitHub. - internal interface IGitHubClient : IDisposable + internal interface IGitHubClient : IModSiteClient, IDisposable { /********* ** Methods diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs new file mode 100644 index 00000000..33277711 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// A client for fetching update check info from a mod site. + internal interface IModSiteClient + { + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey { get; } + + + /********* + ** Methods + *********/ + /// Get update check info about a mod. + /// The mod ID. + Task GetModData(string id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs index 3ede46e2..468b72b1 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop { /// An HTTP client for fetching mod metadata from the ModDrop API. - internal interface IModDropClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The ModDrop mod ID. - /// Returns the mod info if found, else null. - Task GetModAsync(long id); - } + internal interface IModDropClient : IDisposable, IModSiteClient { } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 5ad2d2f8..3a1c5b9d 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -1,6 +1,7 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop @@ -18,6 +19,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop private readonly string ModUrlFormat; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.ModDrop; + + /********* ** Public methods *********/ @@ -31,60 +39,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop this.ModUrlFormat = modUrlFormat; } - /// Get metadata about a mod. - /// The ModDrop mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(long id) + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) { + var page = new GenericModPage(this.SiteKey, id); + + if (!long.TryParse(id, out long parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + // get raw data ModListModel response = await this.Client .PostAsync("") .WithBody(new { - ModIDs = new[] { id }, + ModIDs = new[] { parsedId }, Files = true, Mods = true }) .As(); - ModModel mod = response.Mods[id]; + ModModel mod = response.Mods[parsedId]; if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) return null; - // get latest versions - ISemanticVersion latest = null; - ISemanticVersion optional = null; + // get files + var downloads = new List(); foreach (FileDataModel file in mod.Files) { if (file.IsOld || file.IsDeleted || file.IsHidden) continue; - if (!SemanticVersion.TryParse(file.Version, out ISemanticVersion version)) - continue; - - if (file.IsDefault) - { - if (latest == null || version.IsNewerThan(latest)) - latest = version; - } - else if (optional == null || version.IsNewerThan(optional)) - optional = version; + downloads.Add( + new GenericModDownload(file.Name, file.Description, file.Version) + ); } - if (latest == null) - { - latest = optional; - optional = null; - } - if (optional != null && latest.IsNewerThan(optional)) - optional = null; - // generate result - return new ModDropMod - { - Name = mod.Mod?.Title, - LatestDefaultVersion = latest, - LatestOptionalVersion = optional, - Url = string.Format(this.ModUrlFormat, id) - }; + // return info + string name = mod.Mod?.Title; + string url = string.Format(this.ModUrlFormat, id); + return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs deleted file mode 100644 index def79106..00000000 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop -{ - /// Mod metadata from the ModDrop API. - internal class ModDropMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The latest default file version. - public ISemanticVersion LatestDefaultVersion { get; set; } - - /// The latest optional file version. - public ISemanticVersion LatestOptionalVersion { get; set; } - - /// The mod's web URL. - public string Url { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index fa84b287..b01196f4 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -1,8 +1,21 @@ +using Newtonsoft.Json; + namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels { /// Metadata from the ModDrop API about a mod file. public class FileDataModel { + /// The file title. + [JsonProperty("title")] + public string Name { get; set; } + + /// The file description. + [JsonProperty("desc")] + public string Description { get; set; } + + /// The file version. + public string Version { get; set; } + /// Whether the file is deleted. public bool IsDeleted { get; set; } @@ -14,8 +27,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// Whether this is an archived file. public bool IsOld { get; set; } - - /// The file version. - public string Version { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs index e56e7af4..a44b8c66 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { /// An HTTP client for fetching mod metadata from Nexus Mods. - internal interface INexusClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - Task GetModAsync(uint id); - } + internal interface INexusClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 753d3b4f..ef3ef22e 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -7,6 +7,8 @@ using HtmlAgilityPack; using Pathoschild.FluentNexus.Models; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels; using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; namespace StardewModdingAPI.Web.Framework.Clients.Nexus @@ -30,6 +32,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus private readonly FluentNexusClient ApiClient; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + /********* ** Public methods *********/ @@ -48,20 +57,32 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); } - /// Get metadata about a mod. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(uint id) + /// Get update check info about a mod. + /// The mod ID. + public async Task GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); + // Fetch from the Nexus website when possible, since it has no rate limits. Mods with // adult content are hidden for anonymous users, so fall back to the API in that case. // Note that the API has very restrictive rate limits which means we can't just use it // for all cases. - NexusMod mod = await this.GetModFromWebsiteAsync(id); + NexusMod mod = await this.GetModFromWebsiteAsync(parsedId); if (mod?.Status == NexusModStatus.AdultContentForbidden) - mod = await this.GetModFromApiAsync(id); + mod = await this.GetModFromApiAsync(parsedId); + + // page doesn't exist + if (mod == null || mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); - return mod; + // return info + page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads); + if (mod.Status != NexusModStatus.Ok) + page.SetError(RemoteModStatus.TemporaryError, mod.Error); + return page; } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. @@ -115,37 +136,28 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus // extract mod info string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); + string name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); - // extract file versions - List rawVersions = new List(); + // extract files + var downloads = new List(); foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) { string sectionName = fileSection.Descendants("h2").First().InnerText; if (sectionName != "Main files" && sectionName != "Optional files") continue; - rawVersions.AddRange( - from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) - from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) - select versionStat.InnerText.Trim() - ); - } - - // choose latest file version - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in rawVersions) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; + foreach (var container in fileSection.Descendants("dt")) + { + string fileName = container.GetDataAttribute("name").Value; + string fileVersion = container.GetDataAttribute("version").Value; + string description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next
tag; derived from https://stackoverflow.com/a/25535623/262123 - latestFileVersion = cur; + downloads.Add( + new GenericModDownload(fileName, description, fileVersion) + ); + } } // yield info @@ -153,8 +165,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus { Name = name, Version = parsedVersion?.ToString() ?? version, - LatestFileVersion = latestFileVersion, - Url = url + Url = url, + Downloads = downloads.ToArray() }; } @@ -167,29 +179,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); - // get versions - if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion)) - mainVersion = null; - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in files.Files.Select(p => p.FileVersion)) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (mainVersion != null && !cur.IsNewerThan(mainVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; - - latestFileVersion = cur; - } - // yield info return new NexusMod { Name = mod.Name, Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, - LatestFileVersion = latestFileVersion, - Url = this.GetModUrl(id) + Url = this.GetModUrl(id), + Downloads = files.Files + .Select(file => (IModDownload)new GenericModDownload(file.Name, null, file.FileVersion)) + .ToArray() }; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs deleted file mode 100644 index 0f1b29d5..00000000 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Newtonsoft.Json; - -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// Mod metadata from Nexus Mods. - internal class NexusMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's semantic version number. - public string Version { get; set; } - - /// The latest file version. - public ISemanticVersion LatestFileVersion { get; set; } - - /// The mod's web URL. - [JsonProperty("mod_page_uri")] - public string Url { get; set; } - - /// The mod's publication status. - [JsonIgnore] - public NexusModStatus Status { get; set; } = NexusModStatus.Ok; - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - [JsonIgnore] - public string Error { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs new file mode 100644 index 00000000..aef90ede --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels +{ + /// Mod metadata from Nexus Mods. + internal class NexusMod + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The mod's semantic version number. + public string Version { get; set; } + + /// The mod's web URL. + [JsonProperty("mod_page_uri")] + public string Url { get; set; } + + /// The mod's publication status. + [JsonIgnore] + public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + + /// The files available to download. + [JsonIgnore] + public IModDownload[] Downloads { get; set; } + + /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). + [JsonIgnore] + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index ad7e645a..3a246245 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -13,6 +13,12 @@ namespace StardewModdingAPI.Web.Framework /// Provides extensions on ASP.NET Core types. public static class Extensions { + /********* + ** Public methods + *********/ + /**** + ** View helpers + ****/ /// Get a URL with the absolute path for an action method. Unlike , only the specified are added to the URL without merging values from the current HTTP request. /// The URL helper to extend. /// The name of the action method. diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs new file mode 100644 index 00000000..dc058bcb --- /dev/null +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework +{ + /// Generic metadata about a file download on a mod page. + internal interface IModDownload + { + /// The download's display name. + string Name { get; } + + /// The download's description. + string Description { get; } + + /// The download's file version. + string Version { get; } + } +} diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs new file mode 100644 index 00000000..e66d401f --- /dev/null +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework +{ + /// Generic metadata about a mod page. + internal interface IModPage + { + /********* + ** Accessors + *********/ + /// The mod site containing the mod. + ModSiteKey Site { get; } + + /// The mod's unique ID within the site. + string Id { get; } + + /// The mod name. + string Name { get; } + + /// The mod's semantic version number. + string Version { get; } + + /// The mod's web URL. + string Url { get; } + + /// The mod downloads. + IModDownload[] Downloads { get; } + + /// The mod page status. + RemoteModStatus Status { get; } + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + string Error { get; } + + + /********* + ** Methods + *********/ + /// Set the fetched mod info. + /// The mod name. + /// The mod's semantic version number. + /// The mod's web URL. + /// The mod downloads. + IModPage SetInfo(string name, string version, string url, IEnumerable downloads); + + /// Set a mod fetch error. + /// The mod availability status on the remote site. + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + IModPage SetError(RemoteModStatus status, string error); + } +} diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs new file mode 100644 index 00000000..7845b8c5 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -0,0 +1,81 @@ +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework +{ + /// Generic metadata about a mod. + internal class ModInfoModel + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The mod's latest version. + public ISemanticVersion Version { get; set; } + + /// The mod's latest optional or prerelease version, if newer than . + public ISemanticVersion PreviewVersion { get; set; } + + /// The mod's web URL. + public string Url { get; set; } + + /// The mod availability status on the remote site. + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + + /// The error message indicating why the mod is invalid (if applicable). + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public ModInfoModel() { } + + /// Construct an instance. + /// The mod name. + /// 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 mod's web URL. + public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) + { + this + .SetBasicInfo(name, url) + .SetVersions(version, previewVersion); + } + + /// Set the basic mod info. + /// The mod name. + /// The mod's web URL. + public ModInfoModel SetBasicInfo(string name, string url) + { + this.Name = name; + this.Url = url; + + 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 . + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) + { + this.Version = version; + this.PreviewVersion = previewVersion; + + return this; + } + + /// Set a mod error. + /// The mod availability status on the remote site. + /// The error message indicating why the mod is invalid (if applicable). + public ModInfoModel SetError(RemoteModStatus status, string error) + { + this.Status = status; + this.Error = error; + + return this; + } + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs deleted file mode 100644 index f9f9f47d..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - internal abstract class RepositoryBase : IModRepository - { - /********* - ** Accessors - *********/ - /// The unique key for this vendor. - public ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public abstract void Dispose(); - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public abstract Task GetModInfoAsync(string id); - - - /********* - ** Protected methods - *********/ - /// Construct an instance. - /// The unique key for this vendor. - protected RepositoryBase(ModRepositoryKey vendorKey) - { - this.VendorKey = vendorKey; - } - - /// Normalize a version string. - /// The version to normalize. - protected string NormalizeVersion(string version) - { - if (string.IsNullOrWhiteSpace(version)) - return null; - - version = version.Trim(); - if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix - version = version.Substring(1); - - return version; - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs deleted file mode 100644 index 0945735a..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.Chucklefish; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// An HTTP client for fetching mod metadata from the Chucklefish mod site. - internal class ChucklefishRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IChucklefishClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying HTTP client. - public ChucklefishRepository(IChucklefishClient client) - : base(ModRepositoryKey.Chucklefish) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint realID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); - - // fetch info - try - { - var mod = await this.Client.GetModAsync(realID); - return mod != null - ? new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), url: mod.Url) - : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs deleted file mode 100644 index 93ddc1eb..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.CurseForge; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// An HTTP client for fetching mod metadata from CurseForge. - internal class CurseForgeRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying CurseForge API client. - private readonly ICurseForgeClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying CurseForge API client. - public CurseForgeRepository(ICurseForgeClient client) - : base(ModRepositoryKey.CurseForge) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint curseID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); - - // fetch info - try - { - CurseForgeMod mod = await this.Client.GetModAsync(curseID); - if (mod == null) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - if (mod.Error != null) - { - RemoteModStatus remoteStatus = RemoteModStatus.InvalidData; - return new ModInfoModel().SetError(remoteStatus, mod.Error); - } - - return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs deleted file mode 100644 index c62cb73f..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.GitHub; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// An HTTP client for fetching mod metadata from GitHub project releases. - internal class GitHubRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying GitHub API client. - private readonly IGitHubClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying GitHub API client. - public GitHubRepository(IGitHubClient client) - : base(ModRepositoryKey.GitHub) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task GetModInfoAsync(string id) - { - ModInfoModel result = new ModInfoModel().SetBasicInfo(id, $"https://github.com/{id}/releases"); - - // validate ID format - if (!id.Contains("/") || id.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != id.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) - return result.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); - - // fetch info - try - { - // fetch repo info - GitRepo repository = await this.Client.GetRepositoryAsync(id); - if (repository == null) - return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); - result - .SetBasicInfo(repository.FullName, $"{repository.WebUrl}/releases") - .SetLicense(url: repository.License?.Url, name: repository.License?.SpdxId ?? repository.License?.Name); - - // get latest release (whether preview or stable) - GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); - if (latest == null) - return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); - - // split stable/prerelease if applicable - GitRelease preview = null; - if (latest.IsPrerelease) - { - GitRelease release = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); - if (release != null) - { - preview = latest; - latest = release; - } - } - - // return data - return result.SetVersions(version: this.NormalizeVersion(latest.Tag), previewVersion: this.NormalizeVersion(preview?.Tag)); - } - catch (Exception ex) - { - return result.SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs deleted file mode 100644 index 68f754ae..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// A repository which provides mod metadata. - internal interface IModRepository : IDisposable - { - /********* - ** Accessors - *********/ - /// The unique key for this vendor. - ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - Task GetModInfoAsync(string id); - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs deleted file mode 100644 index 62142668..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.ModDrop; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// An HTTP client for fetching mod metadata from the ModDrop API. - internal class ModDropRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying ModDrop API client. - private readonly IModDropClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying Nexus Mods API client. - public ModDropRepository(IModDropClient client) - : base(ModRepositoryKey.ModDrop) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task GetModInfoAsync(string id) - { - // validate ID format - if (!long.TryParse(id, out long modDropID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); - - // fetch info - try - { - ModDropMod mod = await this.Client.GetModAsync(modDropID); - return mod != null - ? new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url) - : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID."); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs deleted file mode 100644 index 46b98860..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// Generic metadata about a mod. - internal class ModInfoModel - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's latest version. - public string Version { get; set; } - - /// The mod's latest optional or prerelease version, if newer than . - public string PreviewVersion { get; set; } - - /// The mod's web URL. - public string Url { get; set; } - - /// The license URL, if available. - public string LicenseUrl { get; set; } - - /// The license name, if available. - public string LicenseName { get; set; } - - /// The mod availability status on the remote site. - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; - - /// The error message indicating why the mod is invalid (if applicable). - public string Error { get; set; } - - - /********* - ** Public methods - *********/ - /// Construct an empty instance. - public ModInfoModel() { } - - /// Construct an instance. - /// The mod name. - /// 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 mod's web URL. - public ModInfoModel(string name, string version, string url, string previewVersion = null) - { - this - .SetBasicInfo(name, url) - .SetVersions(version, previewVersion); - } - - /// Set the basic mod info. - /// The mod name. - /// The mod's web URL. - public ModInfoModel SetBasicInfo(string name, string url) - { - this.Name = name; - this.Url = url; - - 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 . - public ModInfoModel SetVersions(string version, string previewVersion = null) - { - this.Version = version; - this.PreviewVersion = previewVersion; - - return this; - } - - /// Set the license info, if available. - /// The license URL. - /// The license name. - public ModInfoModel SetLicense(string url, string name) - { - this.LicenseUrl = url; - this.LicenseName = name; - - return this; - } - - /// Set a mod error. - /// The mod availability status on the remote site. - /// The error message indicating why the mod is invalid (if applicable). - public ModInfoModel SetError(RemoteModStatus status, string error) - { - this.Status = status; - this.Error = error; - - return this; - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs deleted file mode 100644 index 9551258c..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.Nexus; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// An HTTP client for fetching mod metadata from Nexus Mods. - internal class NexusRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying Nexus Mods API client. - private readonly INexusClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying Nexus Mods API client. - public NexusRepository(INexusClient client) - : base(ModRepositoryKey.Nexus) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint nexusID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); - - // fetch info - try - { - NexusMod mod = await this.Client.GetModAsync(nexusID); - if (mod == null) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); - if (mod.Error != null) - { - RemoteModStatus remoteStatus = mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished - ? RemoteModStatus.DoesNotExist - : RemoteModStatus.TemporaryError; - return new ModInfoModel().SetError(remoteStatus, mod.Error); - } - - return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs deleted file mode 100644 index 02876556..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// The mod availability status on a remote site. - internal enum RemoteModStatus - { - /// The mod is valid. - Ok, - - /// The mod data was fetched, but the data is not valid (e.g. version isn't semantic). - InvalidData, - - /// The mod does not exist. - DoesNotExist, - - /// The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred). - TemporaryError - } -} diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs new file mode 100644 index 00000000..eaae7935 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework +{ + /// Handles fetching data from mod sites. + internal class ModSiteManager + { + /********* + ** Fields + *********/ + /// The mod sites which provide mod metadata. + private readonly IDictionary ModSites; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod sites which provide mod metadata. + public ModSiteManager(IModSiteClient[] modSites) + { + this.ModSites = modSites.ToDictionary(p => p.SiteKey); + } + + /// Get the mod info for an update key. + /// The namespaced update key. + public async Task GetModPageAsync(UpdateKey updateKey) + { + // get site + if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client)) + return new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Site}'. Expected one of [{string.Join(", ", this.ModSites.Keys)}]."); + + // fetch mod + IModPage mod; + try + { + mod = await client.GetModData(updateKey.ID); + } + catch (Exception ex) + { + mod = new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.TemporaryError, ex.ToString()); + } + + // handle errors + return mod ?? new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"Found no {updateKey.Site} mod with ID '{updateKey.ID}'."); + } + + /// Parse version info for the given mod page info. + /// The mod page info. + /// Maps remote versions to a semantic version for update checks. + /// Whether to allow non-standard versions. + public ModInfoModel GetPageVersions(IModPage page, bool allowNonStandardVersions, IDictionary mapRemoteVersions) + { + // get base model + ModInfoModel model = new ModInfoModel() + .SetBasicInfo(page.Name, page.Url) + .SetError(page.Status, page.Error); + if (page.Status != RemoteModStatus.Ok) + return model; + + // fetch versions + if (!this.TryGetLatestVersions(page, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion)) + return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); + + // return info + return model.SetVersions(mainVersion, previewVersion); + } + + /// Get a semantic local version for update checks. + /// The version to parse. + /// A map of version replacements. + /// Whether to allow non-standard versions. + public ISemanticVersion GetMappedVersion(string version, IDictionary map, bool allowNonStandard) + { + // try mapped version + string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) + return parsedNew; + + // return original version + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) + ? parsedOld + : null; + } + + + /********* + ** Private methods + *********/ + /// Get the mod version numbers for the given mod. + /// The mod to check. + /// Whether to allow non-standard versions. + /// Maps remote versions to a semantic version for update checks. + /// The main mod version. + /// The latest prerelease version, if newer than . + private bool TryGetLatestVersions(IModPage mod, bool allowNonStandardVersions, IDictionary mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + { + main = null; + preview = null; + + ISemanticVersion ParseVersion(string raw) + { + raw = this.NormalizeVersion(raw); + return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); + } + + if (mod != null) + { + // get versions + main = ParseVersion(mod.Version); + foreach (string rawVersion in mod.Downloads.Select(p => p.Version)) + { + ISemanticVersion cur = ParseVersion(rawVersion); + if (cur == null) + continue; + + if (main == null || cur.IsNewerThan(main)) + main = cur; + if (cur.IsPrerelease() && (preview == null || cur.IsNewerThan(preview))) + preview = cur; + } + + if (preview != null && !preview.IsNewerThan(main)) + preview = null; + } + + return main != null; + } + + /// Get a semantic local version for update checks. + /// The version to map. + /// A map of version replacements. + /// Whether to allow non-standard versions. + private string GetRawMappedVersion(string version, IDictionary map, bool allowNonStandard) + { + if (version == null || map == null || !map.Any()) + return version; + + // match exact raw version + if (map.ContainsKey(version)) + return map[version]; + + // match parsed version + if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) + { + if (map.ContainsKey(parsed.ToString())) + return map[parsed.ToString()]; + + foreach ((string fromRaw, string toRaw) in map) + { + if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) + return newVersion.ToString(); + } + } + + return version; + } + + /// Normalize a version string. + /// The version to normalize. + private string NormalizeVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + version = version.Trim(); + if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix + version = version.Substring(1); + + return version; + } + } +} diff --git a/src/SMAPI.Web/Framework/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs new file mode 100644 index 00000000..139ecfd3 --- /dev/null +++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework +{ + /// The mod availability status on a remote site. + internal enum RemoteModStatus + { + /// The mod is valid. + Ok, + + /// The mod data was fetched, but the data is not valid (e.g. version isn't semantic). + InvalidData, + + /// The mod does not exist. + DoesNotExist, + + /// The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred). + TemporaryError + } +} -- cgit