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
/// The mod repositories which provide mod metadata.
private readonly IDictionary Repositories;
- /// The cache in which to store mod metadata.
- private readonly IMemoryCache Cache;
+ /// The cache in which to store wiki data.
+ private readonly IWikiCacheRepository WikiCache;
+
+ /// The cache in which to store mod data.
+ private readonly IModCacheRepository ModCache;
/// The number of minutes successful update checks should be cached before refetching them.
private readonly int SuccessCacheMinutes;
@@ -42,9 +45,6 @@ namespace StardewModdingAPI.Web.Controllers
/// The number of minutes failed update checks should be cached before refetching them.
private readonly int ErrorCacheMinutes;
- /// A regex which matches SMAPI-style semantic version.
- private readonly string VersionRegex;
-
/// The internal mod metadata list.
private readonly ModDatabase ModDatabase;
@@ -57,22 +57,23 @@ namespace StardewModdingAPI.Web.Controllers
*********/
/// Construct an instance.
/// The web hosting environment.
- /// The cache in which to store mod metadata.
+ /// The cache in which to store wiki data.
+ /// The cache in which to store mod metadata.
/// The config settings for mod update checks.
/// The Chucklefish API client.
/// The GitHub API client.
/// The ModDrop API client.
/// The Nexus API client.
- public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions configProvider, IChucklefishClient chucklefish, IGitHubClient github, IModDropClient modDrop, INexusClient nexus)
+ public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions 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 mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase);
foreach (ModSearchEntryModel mod in model.Mods)
{
@@ -218,26 +219,6 @@ namespace StardewModdingAPI.Web.Controllers
return current != null && (other == null || other.IsOlderThan(current));
}
- /// Get mod data from the wiki compatibility list.
- private async Task 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];
- }
- });
- }
-
/// Get the mod info for an update key.
/// The namespaced update key.
private async Task 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();
}
/// Get update keys based on the available mod metadata, while maintaining the precedence order.
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
+{
+ /// 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; }
+
+
+ /*********
+ ** 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;
+ }
+
+ /// Get the API model for the cached data.
+ 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
+{
+ /// Encapsulates logic for accessing the mod data cache.
+ internal interface IModCacheRepository : ICacheRepository
+ {
+ /*********
+ ** 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.
+ bool TryGetMod(ModRepositoryKey site, string id, out CachedMod 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);
+ }
+}
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
+{
+ /// 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.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;
+ }
+
+ /// 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.NormaliseId(id);
+
+ cachedMod = this.SaveMod(new CachedMod(site, id, mod));
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// Save data fetched for a mod.
+ /// The mod data.
+ 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;
+ }
+
+ /// Normalise a mod ID for case-insensitive search.
+ /// The mod ID.
+ 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
/// The number of minutes failed update checks should be cached before refetching them.
public int ErrorCacheMinutes { get; set; }
- /// A regex which matches SMAPI-style semantic version.
- /// Derived from SMAPI's SemanticVersion implementation.
- public string SemanticVersionRegex { get; set; }
-
/// The web URL for the wiki compatibility list.
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
/// The mod's web URL.
public string Url { get; set; }
- /// The mod availability status.
+ /// 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).
@@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
}
/// Set a mod error.
- /// The mod availability status.
+ /// The mod availability status on the remote site.
/// The error message indicating why the mod is invalid (if applicable).
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(serv => new ModCacheRepository(serv.GetRequiredService()));
services.AddSingleton(serv => new WikiCacheRepository(serv.GetRequiredService()));
// 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": "^(?>(?0|[1-9]\\d*))\\.(?>(?0|[1-9]\\d*))(?>(?:\\.(?0|[1-9]\\d*))?)(?:-(?(?>[a-z0-9]+[\\-\\.]?)+))?$",
"CompatibilityPageUrl": "https://mods.smapi.io"
}
}