diff options
Diffstat (limited to 'src/SMAPI.Web')
-rw-r--r-- | src/SMAPI.Web/Controllers/ModsApiController.cs | 68 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs | 97 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs | 26 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs | 96 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs | 4 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs | 4 | ||||
-rw-r--r-- | src/SMAPI.Web/Startup.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/appsettings.json | 1 |
8 files changed, 249 insertions, 49 deletions
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index a74d0d8a..195ee5bf 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -2,17 +2,17 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; @@ -33,8 +33,11 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The mod repositories which provide mod metadata.</summary> private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories; - /// <summary>The cache in which to store mod metadata.</summary> - private readonly IMemoryCache Cache; + /// <summary>The cache in which to store wiki data.</summary> + private readonly IWikiCacheRepository WikiCache; + + /// <summary>The cache in which to store mod data.</summary> + private readonly IModCacheRepository ModCache; /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> private readonly int SuccessCacheMinutes; @@ -42,9 +45,6 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> private readonly int ErrorCacheMinutes; - /// <summary>A regex which matches SMAPI-style semantic version.</summary> - private readonly string VersionRegex; - /// <summary>The internal mod metadata list.</summary> private readonly ModDatabase ModDatabase; @@ -57,22 +57,23 @@ namespace StardewModdingAPI.Web.Controllers *********/ /// <summary>Construct an instance.</summary> /// <param name="environment">The web hosting environment.</param> - /// <param name="cache">The cache in which to store mod metadata.</param> + /// <param name="wikiCache">The cache in which to store wiki data.</param> + /// <param name="modCache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> /// <param name="chucklefish">The Chucklefish API client.</param> /// <param name="github">The GitHub API client.</param> /// <param name="modDrop">The ModDrop API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; this.CompatibilityPageUrl = config.CompatibilityPageUrl; - this.Cache = cache; + this.WikiCache = wikiCache; + this.ModCache = modCache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; this.ErrorCacheMinutes = config.ErrorCacheMinutes; - this.VersionRegex = config.SemanticVersionRegex; this.Repositories = new IModRepository[] { @@ -93,7 +94,7 @@ namespace StardewModdingAPI.Web.Controllers return new ModEntryModel[0]; // fetch wiki data - WikiModEntry[] wikiData = await this.GetWikiDataAsync(); + WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray(); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { @@ -218,26 +219,6 @@ namespace StardewModdingAPI.Web.Controllers return current != null && (other == null || other.IsOlderThan(current)); } - /// <summary>Get mod data from the wiki compatibility list.</summary> - private async Task<WikiModEntry[]> GetWikiDataAsync() - { - ModToolkit toolkit = new ModToolkit(); - return await this.Cache.GetOrCreateAsync("_wiki", async entry => - { - try - { - WikiModEntry[] entries = (await toolkit.GetWikiCompatibilityListAsync()).Mods; - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); - return entries; - } - catch - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes); - return new WikiModEntry[0]; - } - }); - } - /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey) @@ -247,24 +228,27 @@ namespace StardewModdingAPI.Web.Controllers if (!parsed.LooksValid) return new ModInfoModel().WithError(RemoteModStatus.DoesNotExist, $"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); - // get matching repository - if (!this.Repositories.TryGetValue(parsed.Repository, out IModRepository repository)) - return new ModInfoModel().WithError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{parsed.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); - - // fetch mod info - return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{parsed.ID}".ToLower(), async entry => + // get mod + if (!this.ModCache.TryGetMod(parsed.Repository, parsed.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.ErrorCacheMinutes : this.SuccessCacheMinutes)) { + // get site + if (!this.Repositories.TryGetValue(parsed.Repository, out IModRepository repository)) + return new ModInfoModel().WithError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{parsed.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + + // fetch mod ModInfoModel result = await repository.GetModInfoAsync(parsed.ID); if (result.Error == null) { if (result.Version == null) result.WithError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) + else if (!SemanticVersion.TryParse(result.Version, out _)) result.WithError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."); } - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Status == RemoteModStatus.TemporaryError ? this.ErrorCacheMinutes : this.SuccessCacheMinutes); - return result; - }); + + // cache mod + this.ModCache.SaveMod(repository.VendorKey, parsed.ID, result, out mod); + } + return mod.GetModel(); } /// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary> diff --git a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs new file mode 100644 index 00000000..fe8a7a1f --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs @@ -0,0 +1,97 @@ +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 +{ + /// <summary>The model for cached mod data.</summary> + internal class CachedMod + { + /********* + ** Accessors + *********/ + /**** + ** Tracking + ****/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + [BsonIgnoreIfDefault] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>When the data was last requested through the web API.</summary> + public DateTimeOffset LastRequested { get; set; } + + /**** + ** Metadata + ****/ + /// <summary>The mod site on which the mod is found.</summary> + public ModRepositoryKey Site { get; set; } + + /// <summary>The mod's unique ID within the <see cref="Site"/>.</summary> + public string ID { get; set; } + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus FetchStatus { get; set; } + + /// <summary>The error message providing more info for the <see cref="FetchStatus"/>, if applicable.</summary> + public string FetchError { get; set; } + + + /**** + ** Mod info + ****/ + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The mod's latest version.</summary> + public string MainVersion { get; set; } + + /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="MainVersion"/>.</summary> + public string PreviewVersion { get; set; } + + /// <summary>The URL for the mod page.</summary> + public string Url { get; set; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + public CachedMod() { } + + /// <summary>Construct an instance.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + 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; + } + + /// <summary>Get the API model for the cached data.</summary> + public ModInfoModel GetModel() + { + return new ModInfoModel(name: this.Name, version: this.MainVersion, previewVersion: this.PreviewVersion, url: this.Url).WithError(this.FetchStatus, this.FetchError); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs new file mode 100644 index 00000000..23929d1d --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -0,0 +1,26 @@ +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + internal interface IModCacheRepository : ICacheRepository + { + /********* + ** Methods + *********/ + /// <summary>Get the cached mod data.</summary> + /// <param name="site">The mod site to search.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The fetched mod.</param> + /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> + bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true); + + /// <summary>Save data fetched for a mod.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + /// <param name="cachedMod">The stored mod record.</param> + void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs new file mode 100644 index 00000000..d8ad7d21 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs @@ -0,0 +1,96 @@ +using System; +using MongoDB.Driver; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + internal class ModCacheRepository : BaseCacheRepository, IModCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The collection for cached mod data.</summary> + private readonly IMongoCollection<CachedMod> Mods; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="database">The authenticated MongoDB database.</param> + public ModCacheRepository(IMongoDatabase database) + { + // get collections + this.Mods = database.GetCollection<CachedMod>("mods"); + + // add indexes if needed + this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedMod>(Builders<CachedMod>.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); + } + + /********* + ** Public methods + *********/ + /// <summary>Get the cached mod data.</summary> + /// <param name="site">The mod site to search.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The fetched mod.</param> + /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> + public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) + { + // get mod + id = this.NormaliseId(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; + } + + /// <summary>Save data fetched for a mod.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + /// <param name="cachedMod">The stored mod record.</param> + public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) + { + id = this.NormaliseId(id); + + cachedMod = this.SaveMod(new CachedMod(site, id, mod)); + } + + + /********* + ** Private methods + *********/ + /// <summary>Save data fetched for a mod.</summary> + /// <param name="mod">The mod data.</param> + public CachedMod SaveMod(CachedMod mod) + { + string id = this.NormaliseId(mod.ID); + + this.Mods.ReplaceOne( + entry => entry.ID == id && entry.Site == mod.Site, + mod, + new UpdateOptions { IsUpsert = true } + ); + + return mod; + } + + /// <summary>Normalise a mod ID for case-insensitive search.</summary> + /// <param name="id">The mod ID.</param> + public string NormaliseId(string id) + { + return id.Trim().ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index bde566c0..ab935bb3 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -12,10 +12,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> public int ErrorCacheMinutes { get; set; } - /// <summary>A regex which matches SMAPI-style semantic version.</summary> - /// <remarks>Derived from SMAPI's SemanticVersion implementation.</remarks> - public string SemanticVersionRegex { get; set; } - /// <summary>The web URL for the wiki compatibility list.</summary> public string CompatibilityPageUrl { get; set; } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs index 16885bfd..15e6c213 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <summary>The mod's web URL.</summary> public string Url { get; set; } - /// <summary>The mod availability status.</summary> + /// <summary>The mod availability status on the remote site.</summary> public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories } /// <summary>Set a mod error.</summary> - /// <param name="status">The mod availability status.</param> + /// <param name="status">The mod availability status on the remote site.</param> /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> public ModInfoModel WithError(RemoteModStatus status, string error) { diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index caa7b056..fd229b5e 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -13,6 +13,7 @@ using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialisation; 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; using StardewModdingAPI.Web.Framework.Clients.GitHub; @@ -88,6 +89,7 @@ namespace StardewModdingAPI.Web BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); return new MongoClient(mongoConfig.GetConnectionString()).GetDatabase(mongoConfig.Database); }); + services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>())); services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>())); // init Hangfire diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index ea7e9cd2..77d13924 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -66,7 +66,6 @@ "ModUpdateCheck": { "SuccessCacheMinutes": 60, "ErrorCacheMinutes": 5, - "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$", "CompatibilityPageUrl": "https://mods.smapi.io" } } |