using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.ModRepositories;
namespace StardewModdingAPI.Web.Controllers
{
/// Provides an API to perform mod update checks.
[Produces("application/json")]
[Route("api/v{version:semanticVersion}/mods")]
internal class ModsApiController : Controller
{
/*********
** Properties
*********/
/// The mod repositories which provide mod metadata.
private readonly IDictionary Repositories;
/// The cache in which to store mod metadata.
private readonly IMemoryCache Cache;
/// The number of minutes successful update checks should be cached before refetching them.
private readonly int SuccessCacheMinutes;
/// 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;
/*********
** Public methods
*********/
/// Construct an instance.
/// The cache in which to store mod metadata.
/// The config settings for mod update checks.
/// The Chucklefish API client.
/// The GitHub API client.
/// The Nexus API client.
public ModsApiController(IMemoryCache cache, IOptions configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
{
ModUpdateCheckConfig config = configProvider.Value;
this.Cache = cache;
this.SuccessCacheMinutes = config.SuccessCacheMinutes;
this.ErrorCacheMinutes = config.ErrorCacheMinutes;
this.VersionRegex = config.SemanticVersionRegex;
this.Repositories =
new IModRepository[]
{
new ChucklefishRepository(config.ChucklefishKey, chucklefish),
new GitHubRepository(config.GitHubKey, github),
new NexusRepository(config.NexusKey, nexus)
}
.ToDictionary(p => p.VendorKey, StringComparer.CurrentCultureIgnoreCase);
}
/// Fetch version metadata for the given mods.
/// The mod search criteria.
[HttpPost]
public async Task> PostAsync([FromBody] ModSearchModel model)
{
ModSearchEntryModel[] searchMods = this.GetSearchMods(model).ToArray();
IDictionary mods = new Dictionary(StringComparer.CurrentCultureIgnoreCase);
foreach (ModSearchEntryModel mod in searchMods)
{
if (string.IsNullOrWhiteSpace(mod.ID))
continue;
// get latest versions
ModEntryModel result = new ModEntryModel { ID = mod.ID };
IList errors = new List();
foreach (string updateKey in mod.UpdateKeys ?? new string[0])
{
// fetch data
ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey);
if (data.Error != null)
{
errors.Add(data.Error);
continue;
}
// handle main version
if (data.Version != null)
{
if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version))
{
errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'.");
continue;
}
if (result.Version == null || version.IsNewerThan(new SemanticVersion(result.Version)))
{
result.Name = data.Name;
result.Url = data.Url;
result.Version = version.ToString();
}
}
// handle optional version
if (data.PreviewVersion != null)
{
if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version))
{
errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'.");
continue;
}
if (result.PreviewVersion == null || version.IsNewerThan(new SemanticVersion(data.PreviewVersion)))
{
result.Name = result.Name ?? data.Name;
result.PreviewUrl = data.Url;
result.PreviewVersion = version.ToString();
}
}
}
// fallback to preview if latest is invalid
if (result.Version == null && result.PreviewVersion != null)
{
result.Version = result.PreviewVersion;
result.Url = result.PreviewUrl;
result.PreviewVersion = null;
result.PreviewUrl = null;
}
// special cases
if (mod.ID == "Pathoschild.SMAPI")
{
result.Name = "SMAPI";
result.Url = "https://smapi.io/";
if (result.PreviewUrl != null)
result.PreviewUrl = "https://smapi.io/";
}
// add result
result.Errors = errors.ToArray();
mods[mod.ID] = result;
}
return mods;
}
/*********
** Private methods
*********/
/// Parse a namespaced mod ID.
/// The raw mod ID to parse.
/// The parsed vendor key.
/// The parsed mod ID.
/// Returns whether the value could be parsed.
private bool TryParseModKey(string raw, out string vendorKey, out string modID)
{
// split parts
string[] parts = raw?.Split(':');
if (parts == null || parts.Length != 2)
{
vendorKey = null;
modID = null;
return false;
}
// parse
vendorKey = parts[0].Trim();
modID = parts[1].Trim();
return true;
}
/// Get the mods for which the API should return data.
/// The search model.
private IEnumerable GetSearchMods(ModSearchModel model)
{
if (model == null)
yield break;
// yield standard entries
if (model.Mods != null)
{
foreach (ModSearchEntryModel mod in model.Mods)
yield return mod;
}
// yield mod update keys if backwards compatible
if (model.ModKeys != null && model.ModKeys.Any() && this.ShouldBeBackwardsCompatible("2.6-beta.17"))
{
foreach (string updateKey in model.ModKeys.Distinct())
yield return new ModSearchEntryModel(updateKey, new[] { updateKey });
}
}
/// Get the mod info for an update key.
/// The namespaced update key.
private async Task GetInfoForUpdateKeyAsync(string updateKey)
{
// parse update key
if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID))
return new ModInfoModel($"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(vendorKey, out IModRepository repository))
return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
// fetch mod info
return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
{
ModInfoModel result = await repository.GetModInfoAsync(modID);
if (result.Error != null)
{
if (result.Version == null)
result.Error = $"The update key '{updateKey}' matches a mod with no version number.";
else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'.";
}
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
return result;
});
}
/// Get whether the API should return data in a backwards compatible way.
/// The last version for which data should be backwards compatible.
private bool ShouldBeBackwardsCompatible(string maxVersion)
{
string actualVersion = (string)this.RouteData.Values["version"];
return !new SemanticVersion(actualVersion).IsNewerThan(new SemanticVersion(maxVersion));
}
}
}