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.Common.Models;
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 namespaced mod keys to search as a comma-delimited array.
/// Whether to allow non-semantic versions, instead of returning an error for those.
[HttpGet]
public async Task> GetAsync(string modKeys, bool allowInvalidVersions = false)
{
string[] modKeysArray = modKeys?.Split(',').ToArray();
if (modKeysArray == null || !modKeysArray.Any())
return new Dictionary();
return await this.PostAsync(new ModSearchModel(modKeysArray, allowInvalidVersions));
}
/// Fetch version metadata for the given mods.
/// The mod search criteria.
[HttpPost]
public async Task> PostAsync([FromBody] ModSearchModel search)
{
// parse model
bool allowInvalidVersions = search?.AllowInvalidVersions ?? false;
string[] modKeys = (search?.ModKeys?.ToArray() ?? new string[0])
.Distinct(StringComparer.CurrentCultureIgnoreCase)
.OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
// fetch mod info
IDictionary result = new Dictionary(StringComparer.CurrentCultureIgnoreCase);
foreach (string modKey in modKeys)
{
// parse mod key
if (!this.TryParseModKey(modKey, out string vendorKey, out string modID))
{
result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
continue;
}
// get matching repository
if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
{
result[modKey] = new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
continue;
}
// fetch mod info
result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
{
// fetch info
ModInfoModel info = await repository.GetModInfoAsync(modID);
// validate
if (info.Error == null)
{
if (info.Version == null)
info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number.");
if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'.");
}
// cache & return
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
return info;
});
}
return result;
}
/*********
** 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;
}
}
}