diff options
Diffstat (limited to 'src/SMAPI.Web/Framework')
57 files changed, 771 insertions, 385 deletions
diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs index 864aa215..bd414ea2 100644 --- a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Web.Framework public void OnAuthorization(AuthorizationFilterContext context) { IFeatureCollection features = context.HttpContext.Features; - IFormFeature formFeature = features.Get<IFormFeature>(); + IFormFeature? formFeature = features.Get<IFormFeature>(); if (formFeature?.Form == null) { diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs index 52041a16..b393e1e1 100644 --- a/src/SMAPI.Web/Framework/Caching/Cached.cs +++ b/src/SMAPI.Web/Framework/Caching/Cached.cs @@ -10,21 +10,18 @@ namespace StardewModdingAPI.Web.Framework.Caching ** Accessors *********/ /// <summary>The cached data.</summary> - public T Data { get; set; } + public T Data { get; } /// <summary>When the data was last updated.</summary> - public DateTimeOffset LastUpdated { get; set; } + public DateTimeOffset LastUpdated { get; } /// <summary>When the data was last requested through the mod API.</summary> - public DateTimeOffset LastRequested { get; set; } + public DateTimeOffset LastRequested { get; internal set; } /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public Cached() { } - /// <summary>Construct an instance.</summary> /// <param name="data">The cached data.</param> public Cached(T data) diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 0d912c7b..fb74e9da 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 System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <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(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached<IModPage>? 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> diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 9769793c..4ba0bd20 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <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(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true) + public bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached<IModPage>? mod, bool markRequested = true) { // get mod if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index 2ab7ea5a..b8a0df34 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki @@ -12,16 +13,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// <summary>Get the cached wiki metadata.</summary> /// <param name="metadata">The fetched metadata.</param> - bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata); + bool TryGetWikiMetadata([NotNullWhen(true)] out Cached<WikiMetadata>? metadata); /// <summary>Get the cached wiki mods.</summary> /// <param name="filter">A filter to apply, if any.</param> - IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null); + IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool>? filter = null); /// <summary>Save data fetched from the wiki compatibility list.</summary> /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> /// <param name="mods">The mod data.</param> - void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods); + void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable<WikiModEntry> mods); } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs index 064a7c3c..8b4338e2 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -12,10 +13,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Fields *********/ /// <summary>The saved wiki metadata.</summary> - private Cached<WikiMetadata> Metadata; + private Cached<WikiMetadata>? Metadata; /// <summary>The cached wiki data.</summary> - private Cached<WikiModEntry>[] Mods = new Cached<WikiModEntry>[0]; + private Cached<WikiModEntry>[] Mods = Array.Empty<Cached<WikiModEntry>>(); /********* @@ -23,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// <summary>Get the cached wiki metadata.</summary> /// <param name="metadata">The fetched metadata.</param> - public bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata) + public bool TryGetWikiMetadata([NotNullWhen(true)] out Cached<WikiMetadata>? metadata) { metadata = this.Metadata; return metadata != null; @@ -31,7 +32,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// <summary>Get the cached wiki mods.</summary> /// <param name="filter">A filter to apply, if any.</param> - public IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null) + public IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool>? filter = null) { foreach (var mod in this.Mods) { @@ -44,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> /// <param name="mods">The mod data.</param> - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods) + public void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable<WikiModEntry> mods) { this.Metadata = new Cached<WikiMetadata>(new WikiMetadata(stableVersion, betaVersion)); this.Mods = mods.Select(mod => new Cached<WikiModEntry>(mod)).ToArray(); diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs index c04de4a5..f53ea201 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs @@ -7,22 +7,19 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Accessors *********/ /// <summary>The current stable Stardew Valley version.</summary> - public string StableVersion { get; set; } + public string? StableVersion { get; } /// <summary>The current beta Stardew Valley version.</summary> - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public WikiMetadata() { } - - /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> - public WikiMetadata(string stableVersion, string betaVersion) + public WikiMetadata(string? stableVersion, string? betaVersion) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index b8b05878..ce0f1122 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -51,14 +51,14 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); // fetch HTML - string html; + string? html; try { html = await this.Client .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) .AsString(); } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) + catch (ApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } @@ -67,7 +67,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish // extract mod info string url = this.GetModUrl(parsedId); - string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; + string? version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; string name = doc.DocumentNode.SelectSingleNode("//h1").ChildNodes[0].InnerText.Trim(); if (name.StartsWith("[SMAPI]")) name = name.Substring("[SMAPI]".Length).TrimStart(); @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -90,7 +90,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// <param name="id">The mod ID.</param> private string GetModUrl(uint id) { - UriBuilder builder = new UriBuilder(this.Client.BaseClient.BaseAddress); + UriBuilder builder = new(this.Client.BaseClient.BaseAddress!); builder.Path += string.Format(this.ModPageUrlFormat, id); return builder.Uri.ToString(); } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index d8008721..d351b42d 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge private readonly IClient Client; /// <summary>A regex pattern which matches a version number in a CurseForge mod file name.</summary> - private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + private readonly Regex VersionInNamePattern = new(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); /********* @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -49,9 +49,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge 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 + ModModel? mod = await this.Client .GetAsync($"addon/{parsedId}") - .As<ModModel>(); + .As<ModModel?>(); if (mod == null) return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); @@ -71,7 +71,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -80,9 +80,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge *********/ /// <summary>Get a raw version string for a mod file, if available.</summary> /// <param name="file">The file whose version to get.</param> - private string GetRawVersion(ModFileModel file) + private string? GetRawVersion(ModFileModel file) { - Match match = this.VersionInNamePattern.Match(file.DisplayName); + Match match = this.VersionInNamePattern.Match(file.DisplayName ?? ""); if (!match.Success) match = this.VersionInNamePattern.Match(file.FileName); diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs index 9de74847..e9adcf20 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -3,10 +3,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels /// <summary>Metadata from the CurseForge API about a mod file.</summary> public class ModFileModel { + /********* + ** Accessors + *********/ /// <summary>The file name as downloaded.</summary> - public string FileName { get; set; } + public string FileName { get; } /// <summary>The file display name.</summary> - public string DisplayName { get; set; } + public string? DisplayName { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fileName">The file name as downloaded.</param> + /// <param name="displayName">The file display name.</param> + public ModFileModel(string fileName, string? displayName) + { + this.FileName = fileName; + this.DisplayName = displayName; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs index 48cd185b..fd7796f2 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -3,16 +3,36 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels /// <summary>An mod from the CurseForge API.</summary> public class ModModel { + /********* + ** Accessors + *********/ /// <summary>The mod's unique ID on CurseForge.</summary> - public int ID { get; set; } + public int ID { get; } /// <summary>The mod name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The web URL for the mod page.</summary> - public string WebsiteUrl { get; set; } + public string WebsiteUrl { get; } /// <summary>The available file downloads.</summary> - public ModFileModel[] LatestFiles { get; set; } + public ModFileModel[] LatestFiles { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's unique ID on CurseForge.</param> + /// <param name="name">The mod name.</param> + /// <param name="websiteUrl">The web URL for the mod page.</param> + /// <param name="latestFiles">The available file downloads.</param> + public ModModel(int id, string name, string websiteUrl, ModFileModel[] latestFiles) + { + this.ID = id; + this.Name = name; + this.WebsiteUrl = websiteUrl; + this.LatestFiles = latestFiles; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index f08b471c..548f17c3 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -7,26 +7,23 @@ namespace StardewModdingAPI.Web.Framework.Clients ** Accessors *********/ /// <summary>The download's display name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The download's description.</summary> - public string Description { get; set; } + public string? Description { get; } /// <summary>The download's file version.</summary> - public string Version { get; set; } + public string? Version { get; } /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public GenericModDownload() { } - /// <summary>Construct an instance.</summary> /// <param name="name">The download's display name.</param> /// <param name="description">The download's description.</param> /// <param name="version">The download's file version.</param> - public GenericModDownload(string name, string description, string version) + public GenericModDownload(string name, string? description, string? version) { this.Name = name; this.Description = description; diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 622e6c56..5353c7e1 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -17,30 +19,31 @@ namespace StardewModdingAPI.Web.Framework.Clients public string Id { get; set; } /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; set; } /// <summary>The mod's semantic version number.</summary> - public string Version { get; set; } + public string? Version { get; set; } /// <summary>The mod's web URL.</summary> - public string Url { get; set; } + public string? Url { get; set; } /// <summary>The mod downloads.</summary> - public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + public IModDownload[] Downloads { get; set; } = Array.Empty<IModDownload>(); /// <summary>The mod availability status on the remote site.</summary> - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + public RemoteModStatus Status { get; set; } = RemoteModStatus.InvalidData; /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - public string Error { get; set; } + public string? Error { get; set; } + + /// <summary>Whether the mod data is valid.</summary> + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + public bool IsValid => this.Status == RemoteModStatus.Ok; /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public GenericModPage() { } - /// <summary>Construct an instance.</summary> /// <param name="site">The mod site containing the mod.</param> /// <param name="id">The mod's unique ID within the site.</param> @@ -55,12 +58,13 @@ namespace StardewModdingAPI.Web.Framework.Clients /// <param name="version">The mod's semantic version number.</param> /// <param name="url">The mod's web URL.</param> /// <param name="downloads">The mod downloads.</param> - public IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads) + public IModPage SetInfo(string name, string? version, string url, IEnumerable<IModDownload> downloads) { this.Name = name; this.Version = version; this.Url = url; this.Downloads = downloads.ToArray(); + this.Status = RemoteModStatus.Ok; return this; } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs index 73ce4025..dbce9368 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs @@ -5,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>A GitHub download attached to a release.</summary> internal class GitAsset { + /********* + ** Accessors + *********/ /// <summary>The file name.</summary> [JsonProperty("name")] - public string FileName { get; set; } + public string FileName { get; } /// <summary>The file content type.</summary> [JsonProperty("content_type")] - public string ContentType { get; set; } + public string ContentType { get; } /// <summary>The download URL.</summary> [JsonProperty("browser_download_url")] - public string DownloadUrl { get; set; } + public string DownloadUrl { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fileName">The file name.</param> + /// <param name="contentType">The file content type.</param> + /// <param name="downloadUrl">The download URL.</param> + public GitAsset(string fileName, string contentType, string downloadUrl) + { + this.FileName = fileName; + this.ContentType = contentType; + this.DownloadUrl = downloadUrl; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 671f077c..785979a5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -33,26 +33,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <param name="acceptHeader">The Accept header value expected by the GitHub API.</param> /// <param name="username">The username with which to authenticate to the GitHub API.</param> /// <param name="password">The password with which to authenticate to the GitHub API.</param> - public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string? username, string? password) { this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) .AddDefault(req => req.WithHeader("Accept", acceptHeader)); if (!string.IsNullOrWhiteSpace(username)) - this.Client = this.Client.SetBasicAuthentication(username, password); + this.Client = this.Client.SetBasicAuthentication(username, password!); } /// <summary>Get basic metadata for a GitHub repository, if available.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <returns>Returns the repository info if it exists, else <c>null</c>.</returns> - public async Task<GitRepo> GetRepositoryAsync(string repo) + public async Task<GitRepo?> GetRepositoryAsync(string repo) { this.AssertKeyFormat(repo); try { return await this.Client .GetAsync($"repos/{repo}") - .As<GitRepo>(); + .As<GitRepo?>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { @@ -64,7 +64,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> /// <returns>Returns the release if found, else <c>null</c>.</returns> - public async Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false) + public async Task<GitRelease?> GetLatestReleaseAsync(string repo, bool includePrerelease = false) { this.AssertKeyFormat(repo); try @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub return await this.Client .GetAsync($"repos/{repo}/releases/latest") - .As<GitRelease>(); + .As<GitRelease?>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { @@ -89,7 +89,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -97,15 +97,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub 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); + 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; + GitRelease? latest; + GitRelease? preview; { // get latest release (whether preview or stable) latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); @@ -116,7 +116,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub preview = null; if (latest.IsPrerelease) { - GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + GitRelease? release = await this.GetLatestReleaseAsync(id, includePrerelease: false); if (release != null) { preview = latest; @@ -127,8 +127,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub // get downloads IModDownload[] downloads = new[] { latest, preview } - .Where(release => release != null) - .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag)) + .Where(release => release is not null) + .Select(release => (IModDownload)new GenericModDownload(release!.Name, release.Body, release.Tag)) .ToArray(); // return info @@ -138,7 +138,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs index 736efbe6..24d6c3c5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -5,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>The license info for a GitHub project.</summary> internal class GitLicense { + /********* + ** Accessors + *********/ /// <summary>The license display name.</summary> [JsonProperty("name")] - public string Name { get; set; } + public string Name { get; } /// <summary>The SPDX ID for the license.</summary> [JsonProperty("spdx_id")] - public string SpdxId { get; set; } + public string SpdxId { get; } /// <summary>The URL for the license info.</summary> [JsonProperty("url")] - public string Url { get; set; } + public string Url { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The license display name.</param> + /// <param name="spdxId">The SPDX ID for the license.</param> + /// <param name="url">The URL for the license info.</param> + public GitLicense(string name, string spdxId, string url) + { + this.Name = name; + this.SpdxId = spdxId; + this.Url = url; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs index d0db5297..9de6f020 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -1,3 +1,4 @@ +using System; using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.GitHub @@ -10,24 +11,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// <summary>The display name.</summary> [JsonProperty("name")] - public string Name { get; set; } + public string Name { get; } /// <summary>The semantic version string.</summary> [JsonProperty("tag_name")] - public string Tag { get; set; } + public string Tag { get; } /// <summary>The Markdown description for the release.</summary> - public string Body { get; set; } + public string Body { get; internal set; } /// <summary>Whether this is a draft version.</summary> [JsonProperty("draft")] - public bool IsDraft { get; set; } + public bool IsDraft { get; } /// <summary>Whether this is a prerelease version.</summary> [JsonProperty("prerelease")] - public bool IsPrerelease { get; set; } + public bool IsPrerelease { get; } /// <summary>The attached files.</summary> - public GitAsset[] Assets { get; set; } + public GitAsset[] Assets { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The display name.</param> + /// <param name="tag">The semantic version string.</param> + /// <param name="body">The Markdown description for the release.</param> + /// <param name="isDraft">Whether this is a draft version.</param> + /// <param name="isPrerelease">Whether this is a prerelease version.</param> + /// <param name="assets">The attached files.</param> + public GitRelease(string name, string tag, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) + { + this.Name = name; + this.Tag = tag; + this.Body = body ?? string.Empty; + this.IsDraft = isDraft; + this.IsPrerelease = isPrerelease; + this.Assets = assets ?? Array.Empty<GitAsset>(); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs index 7d80576e..879b5e49 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -5,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Basic metadata about a GitHub project.</summary> internal class GitRepo { + /********* + ** Accessors + *********/ /// <summary>The full repository name, including the owner.</summary> [JsonProperty("full_name")] - public string FullName { get; set; } + public string FullName { get; } /// <summary>The URL to the repository web page, if any.</summary> [JsonProperty("html_url")] - public string WebUrl { get; set; } + public string? WebUrl { get; } /// <summary>The code license, if any.</summary> [JsonProperty("license")] - public GitLicense License { get; set; } + public GitLicense? License { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullName">The full repository name, including the owner.</param> + /// <param name="webUrl">The URL to the repository web page, if any.</param> + /// <param name="license">The code license, if any.</param> + public GitRepo(string fullName, string? webUrl, GitLicense? license) + { + this.FullName = fullName; + this.WebUrl = webUrl; + this.License = license; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 0d6f4643..886e32d3 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -12,12 +12,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Get basic metadata for a GitHub repository, if available.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <returns>Returns the repository info if it exists, else <c>null</c>.</returns> - Task<GitRepo> GetRepositoryAsync(string repo); + Task<GitRepo?> GetRepositoryAsync(string repo); /// <summary>Get the latest release for a GitHub repository.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> /// <returns>Returns the release if found, else <c>null</c>.</returns> - Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false); + Task<GitRelease?> GetLatestReleaseAsync(string repo, bool includePrerelease = false); } } diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs index 33277711..3697ffae 100644 --- a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -18,6 +18,6 @@ namespace StardewModdingAPI.Web.Framework.Clients *********/ /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - Task<IModPage> GetModData(string id); + Task<IModPage?> GetModData(string id); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 3a1c5b9d..c60b2c90 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -41,9 +42,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The nullability is validated in this method.")] + public async Task<IModPage?> GetModData(string id) { - var page = new GenericModPage(this.SiteKey, id); + IModPage 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."); @@ -58,9 +60,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop Mods = true }) .As<ModListModel>(); - ModModel mod = response.Mods[parsedId]; - if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) - return null; + + if (!response.Mods.TryGetValue(parsedId, out ModModel? mod) || mod?.Mod is null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop page with this ID."); + if (mod.Mod.ErrorCode is not null) + return page.SetError(RemoteModStatus.InvalidData, $"ModDrop returned error code {mod.Mod.ErrorCode} for mod ID '{id}'."); // get files var downloads = new List<IModDownload>(); @@ -75,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop } // return info - string name = mod.Mod?.Title; + string name = mod.Mod.Title; string url = string.Format(this.ModUrlFormat, id); return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } @@ -83,7 +87,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index b01196f4..31905338 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -5,27 +5,53 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>Metadata from the ModDrop API about a mod file.</summary> public class FileDataModel { + /********* + ** Accessors + *********/ /// <summary>The file title.</summary> [JsonProperty("title")] - public string Name { get; set; } + public string Name { get; } /// <summary>The file description.</summary> [JsonProperty("desc")] - public string Description { get; set; } + public string Description { get; } /// <summary>The file version.</summary> - public string Version { get; set; } + public string Version { get; } /// <summary>Whether the file is deleted.</summary> - public bool IsDeleted { get; set; } + public bool IsDeleted { get; } /// <summary>Whether the file is hidden from users.</summary> - public bool IsHidden { get; set; } + public bool IsHidden { get; } /// <summary>Whether this is the default file for the mod.</summary> - public bool IsDefault { get; set; } + public bool IsDefault { get; } /// <summary>Whether this is an archived file.</summary> - public bool IsOld { get; set; } + public bool IsOld { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The file title.</param> + /// <param name="description">The file description.</param> + /// <param name="version">The file version.</param> + /// <param name="isDeleted">Whether the file is deleted.</param> + /// <param name="isHidden">Whether the file is hidden from users.</param> + /// <param name="isDefault">Whether this is the default file for the mod.</param> + /// <param name="isOld">Whether this is an archived file.</param> + public FileDataModel(string name, string description, string version, bool isDeleted, bool isHidden, bool isDefault, bool isOld) + { + this.Name = name; + this.Description = description; + this.Version = version; + this.IsDeleted = isDeleted; + this.IsHidden = isHidden; + this.IsDefault = isDefault; + this.IsOld = isOld; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs index cfdd6a4e..0654b576 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs @@ -3,13 +3,31 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>Metadata about a mod from the ModDrop API.</summary> public class ModDataModel { + /********* + ** Accessors + *********/ /// <summary>The mod's unique ID on ModDrop.</summary> public int ID { get; set; } + /// <summary>The mod name.</summary> + public string Title { get; set; } + /// <summary>The error code, if any.</summary> public int? ErrorCode { get; set; } - /// <summary>The mod name.</summary> - public string Title { get; set; } + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's unique ID on ModDrop.</param> + /// <param name="title">The mod name.</param> + /// <param name="errorCode">The error code, if any.</param> + public ModDataModel(int id, string title, int? errorCode) + { + this.ID = id; + this.Title = title; + this.ErrorCode = errorCode; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs index 7f692ca1..cb4be35c 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs @@ -5,7 +5,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>A list of mods from the ModDrop API.</summary> public class ModListModel { + /********* + ** Accessors + *********/ /// <summary>The mod data.</summary> - public IDictionary<long, ModModel> Mods { get; set; } + public IDictionary<long, ModModel> Mods { get; } = new Dictionary<long, ModModel>(); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs index 9f4b2c6f..60b818d6 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs @@ -3,10 +3,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>An entry in a mod list from the ModDrop API.</summary> public class ModModel { + /********* + ** Accessors + *********/ /// <summary>The available file downloads.</summary> - public FileDataModel[] Files { get; set; } + public FileDataModel[] Files { get; } /// <summary>The mod metadata.</summary> - public ModDataModel Mod { get; set; } + public ModDataModel Mod { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="files">The available file downloads.</param> + /// <param name="mod">The mod metadata.</param> + public ModModel(FileDataModel[] files, ModDataModel mod) + { + this.Files = files; + this.Mod = mod; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs new file mode 100644 index 00000000..6edd5f64 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// <summary>A client for the Nexus website which does nothing, used for local development.</summary> + internal class DisabledNexusClient : INexusClient + { + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + + /********* + ** Public methods + *********/ + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public Task<IModPage?> GetModData(string id) + { + return Task.FromResult<IModPage?>( + new GenericModPage(ModSiteKey.Nexus, id).SetError(RemoteModStatus.TemporaryError, "The Nexus client is currently disabled due to the configuration.") + ); + } + + /// <inheritdoc /> + public void Dispose() { } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 4ba94f81..23b25f95 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -70,25 +70,25 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus // 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(parsedId); + NexusMod? mod = await this.GetModFromWebsiteAsync(parsedId); if (mod?.Status == NexusModStatus.AdultContentForbidden) mod = await this.GetModFromApiAsync(parsedId); // page doesn't exist - if (mod == null || mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished) + if (mod == null || mod.Status is NexusModStatus.Hidden or NexusModStatus.NotPublished) return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); // return info - page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads); + page.SetInfo(name: mod.Name ?? parsedId.ToString(), url: mod.Url ?? this.GetModUrl(parsedId), version: mod.Version, downloads: mod.Downloads); if (mod.Status != NexusModStatus.Ok) - page.SetError(RemoteModStatus.TemporaryError, mod.Error); + page.SetError(RemoteModStatus.TemporaryError, mod.Error!); return page; } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.WebClient?.Dispose(); + this.WebClient.Dispose(); } @@ -98,7 +98,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>Get metadata about a mod by scraping the Nexus website.</summary> /// <param name="id">The Nexus mod ID.</param> /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - private async Task<NexusMod> GetModFromWebsiteAsync(uint id) + private async Task<NexusMod?> GetModFromWebsiteAsync(uint id) { // fetch HTML string html; @@ -114,35 +114,38 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus } // parse HTML - var doc = new HtmlDocument(); + HtmlDocument doc = new(); doc.LoadHtml(html); // handle Nexus error message - HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); + HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); if (node != null) { string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); string errorCode = errorParts[0]; - string errorText = errorParts.Length > 1 ? errorParts[1] : null; + string? errorText = errorParts.Length > 1 ? errorParts[1] : null; switch (errorCode.Trim().ToLower()) { case "not found": return null; default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = this.GetWebStatus(errorCode) }; + return new NexusMod( + status: this.GetWebStatus(errorCode), + error: $"Nexus error: {errorCode} ({errorText})." + ); } } // extract mod info string url = this.GetModUrl(id); - 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); + 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 files var downloads = new List<IModDownload>(); - foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + foreach (HtmlNode fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) { string sectionName = fileSection.Descendants("h2").First().InnerText; if (sectionName != "Main files" && sectionName != "Optional files") @@ -152,7 +155,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus { 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 <dd> tag; derived from https://stackoverflow.com/a/25535623/262123 + string? description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next <dd> tag; derived from https://stackoverflow.com/a/25535623/262123 downloads.Add( new GenericModDownload(fileName, description, fileVersion) @@ -161,13 +164,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus } // yield info - return new NexusMod - { - Name = name, - Version = parsedVersion?.ToString() ?? version, - Url = url, - Downloads = downloads.ToArray() - }; + return new NexusMod( + name: name ?? id.ToString(), + version: parsedVersion?.ToString() ?? version, + url: url, + downloads: downloads.ToArray() + ); } /// <summary>Get metadata about a mod from the Nexus API.</summary> @@ -180,22 +182,21 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); // yield info - return new NexusMod - { - Name = mod.Name, - Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, - Url = this.GetModUrl(id), - Downloads = files.Files + return new NexusMod( + name: mod.Name, + version: SemanticVersion.TryParse(mod.Version, out ISemanticVersion? version) ? version.ToString() : mod.Version, + url: this.GetModUrl(id), + downloads: files.Files .Select(file => (IModDownload)new GenericModDownload(file.Name, file.Description, file.FileVersion)) .ToArray() - }; + ); } /// <summary>Get the full mod page URL for a given ID.</summary> /// <param name="id">The mod ID.</param> private string GetModUrl(uint id) { - UriBuilder builder = new UriBuilder(this.WebClient.BaseClient.BaseAddress); + UriBuilder builder = new(this.WebClient.BaseClient.BaseAddress!); builder.Path += string.Format(this.WebModUrlFormat, id); return builder.Uri.ToString(); } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs index aef90ede..3155cfda 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -1,3 +1,4 @@ +using System; using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels @@ -9,25 +10,53 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; } /// <summary>The mod's semantic version number.</summary> - public string Version { get; set; } + public string? Version { get; } /// <summary>The mod's web URL.</summary> [JsonProperty("mod_page_uri")] - public string Url { get; set; } + public string? Url { get; } /// <summary>The mod's publication status.</summary> [JsonIgnore] - public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + public NexusModStatus Status { get; } /// <summary>The files available to download.</summary> [JsonIgnore] - public IModDownload[] Downloads { get; set; } + public IModDownload[] Downloads { get; } /// <summary>A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> [JsonIgnore] - public string Error { get; set; } + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name</param> + /// <param name="version">The mod's semantic version number.</param> + /// <param name="url">The mod's web URL.</param> + /// <param name="downloads">The files available to download.</param> + public NexusMod(string name, string? version, string url, IModDownload[] downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Status = NexusModStatus.Ok; + this.Downloads = downloads; + } + + /// <summary>Construct an instance.</summary> + /// <param name="status">The mod's publication status.</param> + /// <param name="error">A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</param> + public NexusMod(NexusModStatus status, string error) + { + this.Status = status; + this.Error = error; + this.Downloads = Array.Empty<IModDownload>(); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 813ea115..7f40e713 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,15 +1,35 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// <summary>The response for a get-paste request.</summary> internal class PasteInfo { + /********* + ** Accessors + *********/ /// <summary>Whether the log was successfully fetched.</summary> - public bool Success { get; set; } + [MemberNotNullWhen(true, nameof(PasteInfo.Content))] + [MemberNotNullWhen(false, nameof(PasteInfo.Error))] + public bool Success => this.Error == null || this.Content != null; /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary> - public string Content { get; set; } + public string? Content { get; internal set; } + + /// <summary>The error message (if <see cref="Success"/> is <c>false</c>).</summary> + public string? Error { get; } + - /// <summary>The error message if saving failed.</summary> - public string Error { get; set; } + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="content">The fetched paste content.</param> + /// <param name="error">The error message, if it failed.</param> + public PasteInfo(string? content, string? error) + { + this.Content = content; + this.Error = error; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 1be00be7..0e00f071 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -33,24 +33,24 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin try { // get from API - string content = await this.Client + string? content = await this.Client .GetAsync($"raw/{id}") .AsString(); // handle Pastebin errors if (string.IsNullOrWhiteSpace(content)) - return new PasteInfo { Error = "Received an empty response from Pastebin." }; + return new PasteInfo(null, "Received an empty response from Pastebin."); if (content.StartsWith("<!DOCTYPE")) - return new PasteInfo { Error = $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it." }; - return new PasteInfo { Success = true, Content = content }; + return new PasteInfo(null, $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it."); + return new PasteInfo(content, null); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { - return new PasteInfo { Error = "There's no log with that ID." }; + return new PasteInfo(null, "There's no log with that ID."); } catch (Exception ex) { - return new PasteInfo { Error = $"Pastebin error: {ex}" }; + return new PasteInfo(null, $"Pastebin error: {ex}"); } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs index 676d660d..e7a2df13 100644 --- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Text; @@ -29,9 +30,9 @@ namespace StardewModdingAPI.Web.Framework.Compression // compressed byte[] compressedData; - using (MemoryStream stream = new MemoryStream()) + using (MemoryStream stream = new()) { - using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) + using (GZipStream zipStream = new(stream, CompressionLevel.Optimal, leaveOpen: true)) zipStream.Write(buffer, 0, buffer.Length); stream.Position = 0; @@ -51,8 +52,12 @@ namespace StardewModdingAPI.Web.Framework.Compression /// <summary>Decompress a string.</summary> /// <param name="rawText">The compressed text.</param> /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> - public string DecompressString(string rawText) + [return: NotNullIfNotNull("rawText")] + public string? DecompressString(string? rawText) { + if (rawText is null) + return rawText; + // get raw bytes byte[] zipBuffer; try @@ -69,7 +74,7 @@ namespace StardewModdingAPI.Web.Framework.Compression return rawText; // decompress - using MemoryStream memoryStream = new MemoryStream(); + using MemoryStream memoryStream = new(); { // read length prefix int dataLength = BitConverter.ToInt32(zipBuffer, 0); @@ -78,7 +83,7 @@ namespace StardewModdingAPI.Web.Framework.Compression // read data byte[] buffer = new byte[dataLength]; memoryStream.Position = 0; - using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + using (GZipStream gZipStream = new(memoryStream, CompressionMode.Decompress)) gZipStream.Read(buffer, 0, buffer.Length); // return original string diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs index a000865e..ef2d5696 100644 --- a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.Compression { /// <summary>Handles GZip compression logic.</summary> @@ -12,6 +14,7 @@ namespace StardewModdingAPI.Web.Framework.Compression /// <summary>Decompress a string.</summary> /// <param name="rawText">The compressed text.</param> - string DecompressString(string rawText); + [return: NotNullIfNotNull("rawText")] + string? DecompressString(string? rawText); } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 878130bf..b582b2b0 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -10,17 +10,17 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Generic ****/ /// <summary>The user agent for API clients, where {0} is the SMAPI version.</summary> - public string UserAgent { get; set; } + public string UserAgent { get; set; } = null!; /**** ** Azure ****/ /// <summary>The connection string for the Azure Blob storage account.</summary> - public string AzureBlobConnectionString { get; set; } + public string? AzureBlobConnectionString { get; set; } /// <summary>The Azure Blob container in which to store temporary uploaded logs.</summary> - public string AzureBlobTempContainer { get; set; } + public string AzureBlobTempContainer { get; set; } = null!; /// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary> public int AzureBlobTempExpiryDays { get; set; } @@ -30,65 +30,65 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Chucklefish ****/ /// <summary>The base URL for the Chucklefish mod site.</summary> - public string ChucklefishBaseUrl { get; set; } + public string ChucklefishBaseUrl { get; set; } = null!; /// <summary>The URL for a mod page on the Chucklefish mod site excluding the <see cref="GitHubBaseUrl"/>, where {0} is the mod ID.</summary> - public string ChucklefishModPageUrlFormat { get; set; } + public string ChucklefishModPageUrlFormat { get; set; } = null!; /**** ** CurseForge ****/ /// <summary>The base URL for the CurseForge API.</summary> - public string CurseForgeBaseUrl { get; set; } + public string CurseForgeBaseUrl { get; set; } = null!; /**** ** GitHub ****/ /// <summary>The base URL for the GitHub API.</summary> - public string GitHubBaseUrl { get; set; } + public string GitHubBaseUrl { get; set; } = null!; /// <summary>The Accept header value expected by the GitHub API.</summary> - public string GitHubAcceptHeader { get; set; } + public string GitHubAcceptHeader { get; set; } = null!; /// <summary>The username with which to authenticate to the GitHub API (if any).</summary> - public string GitHubUsername { get; set; } + public string? GitHubUsername { get; set; } /// <summary>The password with which to authenticate to the GitHub API (if any).</summary> - public string GitHubPassword { get; set; } + public string? GitHubPassword { get; set; } /**** ** ModDrop ****/ /// <summary>The base URL for the ModDrop API.</summary> - public string ModDropApiUrl { get; set; } + public string ModDropApiUrl { get; set; } = null!; /// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary> - public string ModDropModPageUrl { get; set; } + public string ModDropModPageUrl { get; set; } = null!; /**** ** Nexus Mods ****/ /// <summary>The base URL for the Nexus Mods API.</summary> - public string NexusBaseUrl { get; set; } + public string NexusBaseUrl { get; set; } = null!; /// <summary>The URL for a Nexus mod page for the user, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> - public string NexusModUrlFormat { get; set; } + public string NexusModUrlFormat { get; set; } = null!; /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> - public string NexusModScrapeUrlFormat { get; set; } + public string NexusModScrapeUrlFormat { get; set; } = null!; /// <summary>The Nexus API authentication key.</summary> - public string NexusApiKey { get; set; } + public string? NexusApiKey { get; set; } /**** ** Pastebin ****/ /// <summary>The base URL for the Pastebin API.</summary> - public string PastebinBaseUrl { get; set; } + public string PastebinBaseUrl { get; set; } = null!; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs index f382d7b5..e46ecf2b 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs @@ -4,12 +4,12 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels internal class ModOverrideConfig { /// <summary>The unique ID from the mod's manifest.</summary> - public string ID { get; set; } + public string ID { get; set; } = null!; /// <summary>Whether to allow non-standard versions.</summary> public bool AllowNonStandardVersions { get; set; } /// <summary>The mod page URL to use regardless of which site has the update, or <c>null</c> to use the site URL.</summary> - public string SetUrl { get; set; } + public string? SetUrl { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index aea695b8..c3b136e8 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Web.Framework.ConfigModels { /// <summary>The config settings for mod update checks.</summary> @@ -6,16 +8,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> + /// <summary>The number of minutes successful update checks should be cached before re-fetching them.</summary> public int SuccessCacheMinutes { get; set; } - /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> + /// <summary>The number of minutes failed update checks should be cached before re-fetching them.</summary> public int ErrorCacheMinutes { get; set; } /// <summary>Update-check metadata to override.</summary> - public ModOverrideConfig[] ModOverrides { get; set; } + public ModOverrideConfig[] ModOverrides { get; set; } = Array.Empty<ModOverrideConfig>(); /// <summary>The update-check config for SMAPI's own update checks.</summary> - public SmapiInfoConfig SmapiInfo { get; set; } + public SmapiInfoConfig SmapiInfo { get; set; } = null!; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index 664dbef3..62685e47 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -7,9 +7,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Accessors *********/ /// <summary>A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format.</summary> - public string OtherBlurb { get; set; } + public string? OtherBlurb { get; set; } /// <summary>A list of supports to credit on the main page, in Markdown format.</summary> - public string SupporterList { get; set; } + public string? SupporterList { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs index d69fabb3..a95e0048 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs @@ -1,15 +1,17 @@ +using System; + namespace StardewModdingAPI.Web.Framework.ConfigModels { /// <summary>The update-check config for SMAPI's own update checks.</summary> internal class SmapiInfoConfig { /// <summary>The mod ID used for SMAPI update checks.</summary> - public string ID { get; set; } + public string ID { get; set; } = null!; /// <summary>The default update key used for SMAPI update checks.</summary> - public string DefaultUpdateKey { get; set; } + public string DefaultUpdateKey { get; set; } = null!; /// <summary>The update keys to add for SMAPI update checks when the player has a beta version installed.</summary> - public string[] AddBetaUpdateKeys { get; set; } + public string[] AddBetaUpdateKeys { get; set; } = Array.Empty<string>(); } } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index 5305b142..62a23155 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -26,10 +26,10 @@ namespace StardewModdingAPI.Web.Framework /// <param name="values">An object that contains route values.</param> /// <param name="absoluteUrl">Get an absolute URL instead of a server-relative path/</param> /// <returns>The generated URL.</returns> - public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) + public static string? PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object? values = null, bool absoluteUrl = false) { // get route values - RouteValueDictionary valuesDict = new RouteValueDictionary(values); + RouteValueDictionary valuesDict = new(values); foreach (var value in helper.ActionContext.RouteData.Values) { if (!valuesDict.ContainsKey(value.Key)) @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework } // get relative URL - string url = helper.Action(action, controller, valuesDict); + string? url = helper.Action(action, controller, valuesDict); if (url == null && action.EndsWith("Async")) url = helper.Action(action[..^"Async".Length], controller, valuesDict); @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework if (absoluteUrl) { HttpRequest request = helper.ActionContext.HttpContext.Request; - Uri baseUri = new Uri($"{request.Scheme}://{request.Host}"); + Uri baseUri = new($"{request.Scheme}://{request.Host}"); url = new Uri(baseUri, url).ToString(); } @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Web.Framework /// <param name="value">The value to serialize.</param> /// <returns>The serialized JSON.</returns> /// <remarks>This bypasses unnecessary validation (e.g. not allowing null values) in <see cref="IJsonHelper.Serialize"/>.</remarks> - public static IHtmlContent ForJson(this RazorPageBase page, object value) + public static IHtmlContent ForJson(this RazorPageBase page, object? value) { string json = JsonConvert.SerializeObject(value); return new HtmlString(json); diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index dc058bcb..fe171785 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -3,13 +3,16 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Generic metadata about a file download on a mod page.</summary> internal interface IModDownload { + /********* + ** Accessors + *********/ /// <summary>The download's display name.</summary> string Name { get; } /// <summary>The download's description.</summary> - string Description { get; } + string? Description { get; } /// <summary>The download's file version.</summary> - string Version { get; } + string? Version { get; } } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index e66d401f..4d0a8d61 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework @@ -16,13 +17,13 @@ namespace StardewModdingAPI.Web.Framework string Id { get; } /// <summary>The mod name.</summary> - string Name { get; } + string? Name { get; } /// <summary>The mod's semantic version number.</summary> - string Version { get; } + string? Version { get; } /// <summary>The mod's web URL.</summary> - string Url { get; } + string? Url { get; } /// <summary>The mod downloads.</summary> IModDownload[] Downloads { get; } @@ -31,7 +32,12 @@ namespace StardewModdingAPI.Web.Framework RemoteModStatus Status { get; } /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - string Error { get; } + string? Error { get; } + + /// <summary>Whether the mod data is valid.</summary> + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + [MemberNotNullWhen(false, nameof(IModPage.Error))] + bool IsValid { get; } /********* @@ -42,7 +48,7 @@ namespace StardewModdingAPI.Web.Framework /// <param name="version">The mod's semantic version number.</param> /// <param name="url">The mod's web URL.</param> /// <param name="downloads">The mod downloads.</param> - IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads); + IModPage SetInfo(string name, string? version, string url, IEnumerable<IModDownload> downloads); /// <summary>Set a mod fetch error.</summary> /// <param name="status">The mod availability status on the remote site.</param> diff --git a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs index 385c0c91..3c1405eb 100644 --- a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs +++ b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs @@ -9,7 +9,7 @@ namespace StardewModdingAPI.Web.Framework ** Fields *********/ /// <summary>An authorization filter that allows local requests.</summary> - private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new LocalRequestsOnlyAuthorizationFilter(); + private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new(); /********* diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs index 992876ef..a1384b8f 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Text; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -11,7 +12,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing ** Fields *********/ /// <summary>The local time when the next log was posted.</summary> - public string Time { get; set; } + public string? Time { get; set; } /// <summary>The log level for the next log message.</summary> public LogLevel Level { get; set; } @@ -20,16 +21,17 @@ namespace StardewModdingAPI.Web.Framework.LogParsing public int ScreenId { get; set; } /// <summary>The mod name for the next log message.</summary> - public string Mod { get; set; } + public string? Mod { get; set; } /// <summary>The text for the next log message.</summary> - private readonly StringBuilder Text = new StringBuilder(); + private readonly StringBuilder Text = new(); /********* ** Accessors *********/ /// <summary>Whether the next log message has been started.</summary> + [MemberNotNullWhen(true, nameof(LogMessageBuilder.Time), nameof(LogMessageBuilder.Mod))] public bool Started { get; private set; } @@ -70,19 +72,18 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } /// <summary>Get a log message for the accumulated values.</summary> - public LogMessage Build() + public LogMessage? Build() { if (!this.Started) return null; - return new LogMessage - { - Time = this.Time, - Level = this.Level, - ScreenId = this.ScreenId, - Mod = this.Mod, - Text = this.Text.ToString() - }; + return new LogMessage( + time: this.Time, + level: this.Level, + screenId: this.ScreenId, + mod: this.Mod, + text: this.Text.ToString() + ); } /// <summary>Reset to start a new log message.</summary> diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs index 5d4c8c08..3f815e3e 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs @@ -10,6 +10,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing *********/ /// <summary>Construct an instance.</summary> /// <param name="message">The user-friendly error message.</param> - public LogParseException(string message) : base(message) { } + public LogParseException(string message) + : base(message) { } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 887d0105..55272b23 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -14,38 +14,38 @@ namespace StardewModdingAPI.Web.Framework.LogParsing ** Fields *********/ /// <summary>A regex pattern matching the start of a SMAPI message.</summary> - private readonly Regex MessageHeaderPattern = new Regex(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+)(?: +screen_(?<screen>\d+))? +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex MessageHeaderPattern = new(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+)(?: +screen_(?<screen>\d+))? +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's initial platform info message.</summary> - private readonly Regex InfoLinePattern = new Regex(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex InfoLinePattern = new(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's mod folder path line.</summary> - private readonly Regex ModPathPattern = new Regex(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModPathPattern = new(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's log timestamp line.</summary> - private readonly Regex LogStartedAtPattern = new Regex(@"^Log started at (?<timestamp>.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex LogStartedAtPattern = new(@"^Log started at (?<timestamp>.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod list.</summary> - private readonly Regex ModListStartPattern = new Regex(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListStartPattern = new(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary> /// <remarks>The author name and description are optional.</remarks> - private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListEntryPattern = new(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary> - private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListStartPattern = new(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> - private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListStartPattern = new(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary> - private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListEntryPattern = new(@"^ (?<name>.+) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> - private readonly Regex SmapiUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SmapiUpdatePattern = new(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing *********/ /// <summary>Parse SMAPI log text.</summary> /// <param name="logText">The SMAPI log text.</param> - public ParsedLog Parse(string logText) + public ParsedLog Parse(string? logText) { try { @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } // init log - ParsedLog log = new ParsedLog + ParsedLog log = new() { IsValid = true, RawText = logText, @@ -77,8 +77,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing }; // parse log messages - LogModInfo smapiMod = new LogModInfo { Name = "SMAPI", Author = "Pathoschild", Description = "", Loaded = true }; - LogModInfo gameMod = new LogModInfo { Name = "game", Author = "", Description = "", Loaded = true }; + LogModInfo smapiMod = new(name: "SMAPI", author: "Pathoschild", version: "", description: "", loaded: true); + LogModInfo gameMod = new(name: "game", author: "", version: "", description: "", loaded: true); IDictionary<string, List<LogModInfo>> mods = new Dictionary<string, List<LogModInfo>>(); bool inModList = false; bool inContentPackList = false; @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing default: if (mods.TryGetValue(message.Mod, out var entries)) { - foreach (var entry in entries) + foreach (LogModInfo entry in entries) entry.Errors++; } break; @@ -131,9 +131,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; - if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); - entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, Loaded = true }); + entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, loaded: true)); message.Section = LogSection.ModsList; } @@ -154,9 +154,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string description = match.Groups["description"].Value; string forMod = match.Groups["for"].Value; - if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); - entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, ContentPackFor = forMod, Loaded = true }); + entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, contentPackFor: forMod, loaded: true)); message.Section = LogSection.ContentPackList; } @@ -177,23 +177,19 @@ namespace StardewModdingAPI.Web.Framework.LogParsing if (mods.TryGetValue(name, out var entries)) { - foreach (var entry in entries) - { - entry.UpdateLink = link; - entry.UpdateVersion = version; - } + foreach (LogModInfo entry in entries) + entry.SetUpdate(version, link); } message.Section = LogSection.ModUpdateList; } - else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; - smapiMod.UpdateVersion = version; - smapiMod.UpdateLink = link; + + smapiMod.SetUpdate(version, link); } // platform info line @@ -203,7 +199,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing log.ApiVersion = match.Groups["apiVersion"].Value; log.GameVersion = match.Groups["gameVersion"].Value; log.OperatingSystem = match.Groups["os"].Value; - smapiMod.Version = log.ApiVersion; + smapiMod.OverrideVersion(log.ApiVersion); } // mod path line @@ -211,9 +207,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing { Match match = this.ModPathPattern.Match(message.Text); log.ModPath = match.Groups["path"].Value; - int lastDelimiterPos = log.ModPath.LastIndexOfAny(new char[] { '/', '\\' }); + int lastDelimiterPos = log.ModPath.LastIndexOfAny(new[] { '/', '\\' }); log.GamePath = lastDelimiterPos >= 0 - ? log.ModPath.Substring(0, lastDelimiterPos) + ? log.ModPath[..lastDelimiterPos] : log.ModPath; } @@ -227,7 +223,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } // finalize log - gameMod.Version = log.GameVersion; + if (log.GameVersion != null) + gameMod.OverrideVersion(log.GameVersion); log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.SelectMany(p => p).OrderBy(p => p.Name)).ToArray(); return log; } @@ -259,7 +256,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <param name="messages">The messages to filter.</param> private IEnumerable<LogMessage> CollapseRepeats(IEnumerable<LogMessage> messages) { - LogMessage next = null; + LogMessage? next = null; + foreach (LogMessage message in messages) { // new message @@ -280,7 +278,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing yield return next; next = message; } - yield return next; + + if (next != null) + yield return next; } /// <summary>Split a SMAPI log into individual log messages.</summary> @@ -288,12 +288,12 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <exception cref="LogParseException">The log text can't be parsed successfully.</exception> private IEnumerable<LogMessage> GetMessages(string logText) { - LogMessageBuilder builder = new LogMessageBuilder(); - using StringReader reader = new StringReader(logText); + LogMessageBuilder builder = new(); + using StringReader reader = new(logText); while (true) { // read line - string line = reader.ReadLine(); + string? line = reader.ReadLine(); if (line == null) break; @@ -306,17 +306,17 @@ namespace StardewModdingAPI.Web.Framework.LogParsing { if (builder.Started) { - yield return builder.Build(); + yield return builder.Build()!; builder.Clear(); } - var screenGroup = header.Groups["screen"]; + Group screenGroup = header.Groups["screen"]; builder.Start( time: header.Groups["time"].Value, level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true), screenId: screenGroup.Success ? int.Parse(screenGroup.Value) : 0, // main player is always screen ID 0 mod: header.Groups["modName"].Value, - text: line.Substring(header.Length) + text: line[header.Length..] ); } else @@ -330,7 +330,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // end last message if (builder.Started) - yield return builder.Build(); + yield return builder.Build()!; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs index 1e08be78..7a5f32e0 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.LogParsing.Models { /// <summary>A parsed log message.</summary> @@ -7,19 +9,19 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Accessors *********/ /// <summary>The local time when the log was posted.</summary> - public string Time { get; set; } + public string Time { get; } /// <summary>The log level.</summary> - public LogLevel Level { get; set; } + public LogLevel Level { get; } /// <summary>The screen ID in split-screen mode.</summary> - public int ScreenId { get; set; } + public int ScreenId { get; } /// <summary>The mod name.</summary> - public string Mod { get; set; } + public string Mod { get; } /// <summary>The log text.</summary> - public string Text { get; set; } + public string Text { get; } /// <summary>The number of times this message was repeated consecutively.</summary> public int Repeated { get; set; } @@ -28,6 +30,32 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models public LogSection? Section { get; set; } /// <summary>Whether this message is the first one of its section.</summary> + [MemberNotNullWhen(true, nameof(LogMessage.Section))] public bool IsStartOfSection { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance/</summary> + /// <param name="time">The local time when the log was posted.</param> + /// <param name="level">The log level.</param> + /// <param name="screenId">The screen ID in split-screen mode.</param> + /// <param name="mod">The mod name.</param> + /// <param name="text">The log text.</param> + /// <param name="repeated">The number of times this message was repeated consecutively.</param> + /// <param name="section">The section that this log message belongs to.</param> + /// <param name="isStartOfSection">Whether this message is the first one of its section.</param> + public LogMessage(string time, LogLevel level, int screenId, string mod, string text, int repeated = 0, LogSection? section = null, bool isStartOfSection = false) + { + this.Time = time; + this.Level = level; + this.ScreenId = screenId; + this.Mod = mod; + this.Text = text; + this.Repeated = repeated; + this.Section = section; + this.IsStartOfSection = isStartOfSection; + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs index 067e4df4..a6b9165c 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.LogParsing.Models { /// <summary>Metadata about a mod or content pack in the log.</summary> @@ -7,33 +9,81 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The mod author.</summary> - public string Author { get; set; } - - /// <summary>The update version.</summary> - public string UpdateVersion { get; set; } - - /// <summary>The update link.</summary> - public string UpdateLink { get; set; } + public string Author { get; } /// <summary>The mod version.</summary> - public string Version { get; set; } + public string Version { get; private set; } /// <summary>The mod description.</summary> - public string Description { get; set; } + public string Description { get; } + + /// <summary>The update version.</summary> + public string? UpdateVersion { get; private set; } + + /// <summary>The update link.</summary> + public string? UpdateLink { get; private set; } /// <summary>The name of the mod for which this is a content pack (if applicable).</summary> - public string ContentPackFor { get; set; } + public string? ContentPackFor { get; } /// <summary>The number of errors logged by this mod.</summary> public int Errors { get; set; } /// <summary>Whether the mod was loaded into the game.</summary> - public bool Loaded { get; set; } + public bool Loaded { get; } /// <summary>Whether the mod has an update available.</summary> + [MemberNotNullWhen(true, nameof(LogModInfo.UpdateVersion), nameof(LogModInfo.UpdateLink))] public bool HasUpdate => this.UpdateVersion != null && this.Version != this.UpdateVersion; + + /// <summary>Whether the mod is a content pack for another mod.</summary> + [MemberNotNullWhen(true, nameof(LogModInfo.ContentPackFor))] + public bool IsContentPack => !string.IsNullOrWhiteSpace(this.ContentPackFor); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name.</param> + /// <param name="author">The mod author.</param> + /// <param name="version">The mod version.</param> + /// <param name="description">The mod description.</param> + /// <param name="updateVersion">The update version.</param> + /// <param name="updateLink">The update link.</param> + /// <param name="contentPackFor">The name of the mod for which this is a content pack (if applicable).</param> + /// <param name="errors">The number of errors logged by this mod.</param> + /// <param name="loaded">Whether the mod was loaded into the game.</param> + public LogModInfo(string name, string author, string version, string description, string? updateVersion = null, string? updateLink = null, string? contentPackFor = null, int errors = 0, bool loaded = true) + { + this.Name = name; + this.Author = author; + this.Version = version; + this.Description = description; + this.UpdateVersion = updateVersion; + this.UpdateLink = updateLink; + this.ContentPackFor = contentPackFor; + this.Errors = errors; + this.Loaded = loaded; + } + + /// <summary>Add an update alert for this mod.</summary> + /// <param name="updateVersion">The update version.</param> + /// <param name="updateLink">The update link.</param> + public void SetUpdate(string updateVersion, string updateLink) + { + this.UpdateVersion = updateVersion; + this.UpdateLink = updateLink; + } + + /// <summary>Override the version number, for cases like SMAPI itself where the version is only known later during parsing.</summary> + /// <param name="version">The new mod version.</param> + public void OverrideVersion(string version) + { + this.Version = version; + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index 87b20eb0..6951e434 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.LogParsing.Models { @@ -12,39 +13,40 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Metadata ****/ /// <summary>Whether the log file was successfully parsed.</summary> + [MemberNotNullWhen(true, nameof(ParsedLog.RawText))] public bool IsValid { get; set; } /// <summary>An error message indicating why the log file is invalid.</summary> - public string Error { get; set; } + public string? Error { get; set; } /// <summary>The raw log text.</summary> - public string RawText { get; set; } + public string? RawText { get; set; } /**** ** Log data ****/ /// <summary>The SMAPI version.</summary> - public string ApiVersion { get; set; } + public string? ApiVersion { get; set; } /// <summary>The game version.</summary> - public string GameVersion { get; set; } + public string? GameVersion { get; set; } /// <summary>The player's operating system.</summary> - public string OperatingSystem { get; set; } + public string? OperatingSystem { get; set; } /// <summary>The game install path.</summary> - public string GamePath { get; set; } + public string? GamePath { get; set; } /// <summary>The mod folder path.</summary> - public string ModPath { get; set; } + public string? ModPath { get; set; } /// <summary>The ISO 8601 timestamp when the log was started.</summary> public DateTimeOffset Timestamp { get; set; } /// <summary>Metadata about installed mods and content packs.</summary> - public LogModInfo[] Mods { get; set; } = new LogModInfo[0]; + public LogModInfo[] Mods { get; set; } = Array.Empty<LogModInfo>(); /// <summary>The log messages.</summary> - public LogMessage[] Messages { get; set; } + public LogMessage[] Messages { get; set; } = Array.Empty<LogMessage>(); } } diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index 7845b8c5..e70b60bf 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -1,4 +1,5 @@ -using StardewModdingAPI.Web.Framework.Clients; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework { @@ -9,22 +10,22 @@ namespace StardewModdingAPI.Web.Framework ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; private set; } + + /// <summary>The mod's web URL.</summary> + public string? Url { get; private set; } /// <summary>The mod's latest version.</summary> - public ISemanticVersion Version { get; set; } + public ISemanticVersion? Version { get; private set; } /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary> - public ISemanticVersion PreviewVersion { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } + public ISemanticVersion? PreviewVersion { get; private set; } /// <summary>The mod availability status on the remote site.</summary> - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + public RemoteModStatus Status { get; private set; } /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> - public string Error { get; set; } + public string? Error { get; private set; } /********* @@ -35,19 +36,24 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Construct an instance.</summary> /// <param name="name">The mod name.</param> + /// <param name="url">The mod's web URL.</param> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> - /// <param name="url">The mod's web URL.</param> - public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) + /// <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> + [JsonConstructor] + public ModInfoModel(string name, string url, ISemanticVersion? version, ISemanticVersion? previewVersion = null, RemoteModStatus status = RemoteModStatus.Ok, string? error = null) { this .SetBasicInfo(name, url) - .SetVersions(version, previewVersion); + .SetVersions(version!, previewVersion) + .SetError(status, error!); } /// <summary>Set the basic mod info.</summary> /// <param name="name">The mod name.</param> /// <param name="url">The mod's web URL.</param> + [MemberNotNull(nameof(ModInfoModel.Name), nameof(ModInfoModel.Url))] public ModInfoModel SetBasicInfo(string name, string url) { this.Name = name; @@ -59,7 +65,8 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Set the mod version info.</summary> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) + [MemberNotNull(nameof(ModInfoModel.Version))] + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null) { this.Version = version; this.PreviewVersion = previewVersion; diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index a2b92aa4..674b9ffc 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -34,12 +35,15 @@ namespace StardewModdingAPI.Web.Framework /// <param name="updateKey">The namespaced update key.</param> public async Task<IModPage> GetModPageAsync(UpdateKey updateKey) { + if (!updateKey.LooksValid) + return new GenericModPage(updateKey.Site, updateKey.ID!).SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); + // get site - if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client)) + 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; + IModPage? mod; try { mod = await client.GetModData(updateKey.ID); @@ -58,39 +62,42 @@ namespace StardewModdingAPI.Web.Framework /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { // get base model - ModInfoModel model = new ModInfoModel() - .SetBasicInfo(page.Name, page.Url) - .SetError(page.Status, page.Error); - if (page.Status != RemoteModStatus.Ok) + ModInfoModel model = new(); + if (page.IsValid) + model.SetBasicInfo(page.Name, page.Url); + else + { + model.SetError(page.Status, page.Error); return model; + } // fetch versions - bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion); + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion); if (!hasVersions && subkey != null) hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); if (!hasVersions) return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); // return info - return model.SetVersions(mainVersion, previewVersion); + return model.SetVersions(mainVersion!, previewVersion); } /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to parse.</param> /// <param name="map">Changes to apply to the raw version, if any.</param> /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - public ISemanticVersion GetMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) + public ISemanticVersion? GetMappedVersion(string? version, ChangeDescriptor? map, bool allowNonStandard) { // try mapped version - string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); - if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) + string? rawNewVersion = this.GetRawMappedVersion(version, map); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion? parsedNew)) return parsedNew; // return original version - return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion? parsedOld) ? parsedOld : null; } @@ -106,31 +113,31 @@ namespace StardewModdingAPI.Web.Framework /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="main">The main mod version.</param> /// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param> - private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview) { main = null; preview = null; // parse all versions from the mod page - IEnumerable<(string name, string description, ISemanticVersion version)> GetAllVersions() + IEnumerable<(string? name, string? description, ISemanticVersion? version)> GetAllVersions() { if (mod != null) { - ISemanticVersion ParseAndMapVersion(string raw) + ISemanticVersion? ParseAndMapVersion(string? raw) { raw = this.NormalizeVersion(raw); return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); } // get mod version - ISemanticVersion modVersion = ParseAndMapVersion(mod.Version); + ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); if (modVersion != null) yield return (name: null, description: null, version: ParseAndMapVersion(mod.Version)); // get file versions foreach (IModDownload download in mod.Downloads) { - ISemanticVersion cur = ParseAndMapVersion(download.Version); + ISemanticVersion? cur = ParseAndMapVersion(download.Version); if (cur != null) yield return (download.Name, download.Description, cur); } @@ -141,15 +148,15 @@ namespace StardewModdingAPI.Web.Framework .ToArray(); // get main + preview versions - void TryGetVersions(out ISemanticVersion mainVersion, out ISemanticVersion previewVersion, Func<(string name, string description, ISemanticVersion version), bool> filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(string? name, string? description, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; // get latest main + preview version - foreach (var entry in versions) + foreach ((string? name, string? description, ISemanticVersion? version) entry in versions) { - if (filter?.Invoke(entry) == false) + if (entry.version is null || filter?.Invoke(entry) == false) continue; if (entry.version.IsPrerelease()) @@ -158,7 +165,7 @@ namespace StardewModdingAPI.Web.Framework mainVersion ??= entry.version; if (mainVersion != null) - break; // any other values will be older + break; // any others will be older since entries are sorted by version } // normalize values @@ -181,8 +188,7 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to map.</param> /// <param name="map">Changes to apply to the raw version, if any.</param> - /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - private string GetRawMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) + private string? GetRawMappedVersion(string? version, ChangeDescriptor? map) { if (version == null || map?.HasChanges != true) return version; @@ -195,7 +201,7 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Normalize a version string.</summary> /// <param name="version">The version to normalize.</param> - private string NormalizeVersion(string version) + private string? NormalizeVersion(string? version) { if (string.IsNullOrWhiteSpace(version)) return null; diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs index d75ee791..7b8f0ec9 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules ** Fields *********/ /// <summary>Maps a lowercase hostname to the resulting redirect URL.</summary> - private readonly Func<string, string> Map; + private readonly Func<string, string?> Map; /********* @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Construct an instance.</summary> /// <param name="statusCode">The status code to use for redirects.</param> /// <param name="map">Hostnames mapped to the resulting redirect URL.</param> - public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string> map) + public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string?> map) { this.StatusCode = statusCode; this.Map = map ?? throw new ArgumentNullException(nameof(map)); @@ -33,12 +33,10 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { // get requested host - string host = context.HttpContext.Request.Host.Host; - if (host == null) - return null; + string? host = context.HttpContext.Request.Host.Host; // get new host host = this.Map(host); diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs index 6e81c4ca..b46e8f69 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <param name="context">The rewrite context.</param> public void ApplyRule(RewriteContext context) { - string newUrl = this.GetNewUrl(context); + string? newUrl = this.GetNewUrl(context); if (newUrl == null) return; @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected abstract string GetNewUrl(RewriteContext context); + protected abstract string? GetNewUrl(RewriteContext context); /// <summary>Get the full request URL.</summary> /// <param name="request">The request.</param> diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs index d9d44641..e691ffba 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs @@ -37,9 +37,9 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { - string path = context.HttpContext.Request.Path.Value; + string? path = context.HttpContext.Request.Path.Value; if (!string.IsNullOrWhiteSpace(path)) { diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs index 2a503ae3..01807608 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs @@ -20,9 +20,9 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules *********/ /// <summary>Construct an instance.</summary> /// <param name="except">Matches requests which should be ignored.</param> - public RedirectToHttpsRule(Func<HttpRequest, bool> except = null) + public RedirectToHttpsRule(Func<HttpRequest, bool>? except = null) { - this.Except = except ?? (req => false); + this.Except = except ?? (_ => false); this.StatusCode = HttpStatusCode.RedirectKeepVerb; } @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { HttpRequest request = context.HttpContext.Request; if (request.IsHttps || this.Except(request)) diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs index c6f8bac1..effbbc9f 100644 --- a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs +++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs @@ -63,11 +63,11 @@ namespace StardewModdingAPI.Web.Framework.Storage BlobClient blob = this.GetAzureBlobClient(id); await blob.UploadAsync(stream); - return new UploadResult(true, id, null); + return new UploadResult(id, null); } catch (Exception ex) { - return new UploadResult(false, null, ex.Message); + return new UploadResult(null, ex.Message); } } @@ -75,10 +75,10 @@ namespace StardewModdingAPI.Web.Framework.Storage else { string path = this.GetDevFilePath(id); - Directory.CreateDirectory(Path.GetDirectoryName(path)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, content); - return new UploadResult(true, id, null); + return new UploadResult(id, null); } } @@ -103,26 +103,20 @@ namespace StardewModdingAPI.Web.Framework.Storage // fetch file Response<BlobDownloadInfo> response = await blob.DownloadAsync(); using BlobDownloadInfo result = response.Value; - using StreamReader reader = new StreamReader(result.Content); + using StreamReader reader = new(result.Content); DateTimeOffset expiry = result.Details.LastModified + TimeSpan.FromDays(this.ExpiryDays); string content = this.GzipHelper.DecompressString(reader.ReadToEnd()); // build model - return new StoredFileInfo - { - Success = true, - Content = content, - Expiry = expiry.UtcDateTime - }; + return new StoredFileInfo(content, expiry); } catch (RequestFailedException ex) { - return new StoredFileInfo - { - Error = ex.ErrorCode == "BlobNotFound" + return new StoredFileInfo( + error: ex.ErrorCode == "BlobNotFound" ? "There's no file with that ID." : $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})." - }; + ); } } @@ -130,15 +124,12 @@ namespace StardewModdingAPI.Web.Framework.Storage else { // get file - FileInfo file = new FileInfo(this.GetDevFilePath(id)); + FileInfo file = new(this.GetDevFilePath(id)); if (file.Exists && file.LastWriteTimeUtc.AddDays(this.ExpiryDays) < DateTime.UtcNow) // expired file.Delete(); if (!file.Exists) { - return new StoredFileInfo - { - Error = "There's no file with that ID." - }; + return new StoredFileInfo(error: "There's no file with that ID."); } // renew @@ -149,13 +140,11 @@ namespace StardewModdingAPI.Web.Framework.Storage } // build model - return new StoredFileInfo - { - Success = true, - Content = File.ReadAllText(file.FullName), - Expiry = DateTime.UtcNow.AddDays(this.ExpiryDays), - Warning = "This file was saved temporarily to the local computer. This should only happen in a local development environment." - }; + return new StoredFileInfo( + content: File.ReadAllText(file.FullName), + expiry: DateTime.UtcNow.AddDays(this.ExpiryDays), + warning: "This file was saved temporarily to the local computer. This should only happen in a local development environment." + ); } } @@ -164,12 +153,7 @@ namespace StardewModdingAPI.Web.Framework.Storage { PasteInfo response = await this.Pastebin.GetAsync(id); response.Content = this.GzipHelper.DecompressString(response.Content); - return new StoredFileInfo - { - Success = response.Success, - Content = response.Content, - Error = response.Error - }; + return new StoredFileInfo(response.Content, null, error: response.Error); } } @@ -177,8 +161,8 @@ namespace StardewModdingAPI.Web.Framework.Storage /// <param name="id">The file ID.</param> private BlobClient GetAzureBlobClient(string id) { - var azure = new BlobServiceClient(this.ClientsConfig.AzureBlobConnectionString); - var container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); + BlobServiceClient azure = new(this.ClientsConfig.AzureBlobConnectionString); + BlobContainerClient container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); return container.GetBlobClient($"uploads/{id}"); } diff --git a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs index 30676c88..bbbcf2a9 100644 --- a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs +++ b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs @@ -1,23 +1,52 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.Storage { /// <summary>The response for a get-file request.</summary> internal class StoredFileInfo { + /********* + ** Accessors + *********/ /// <summary>Whether the file was successfully fetched.</summary> - public bool Success { get; set; } + [MemberNotNullWhen(true, nameof(StoredFileInfo.Content))] + public bool Success => this.Content != null && this.Error == null; /// <summary>The fetched file content (if <see cref="Success"/> is <c>true</c>).</summary> - public string Content { get; set; } + public string? Content { get; } /// <summary>When the file will no longer be available.</summary> - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; } /// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary> - public string Warning { get; set; } + public string? Warning { get; } /// <summary>The error message if saving failed.</summary> - public string Error { get; set; } + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="content">The fetched file content (if <see cref="Success"/> is <c>true</c>).</param> + /// <param name="expiry">When the file will no longer be available.</param> + /// <param name="warning">The error message if saving succeeded, but a non-blocking issue was encountered.</param> + /// <param name="error">The error message if saving failed.</param> + public StoredFileInfo(string? content, DateTimeOffset? expiry, string? warning = null, string? error = null) + { + this.Content = content; + this.Expiry = expiry; + this.Warning = warning; + this.Error = error; + } + + /// <summary>Construct an instance.</summary> + /// <param name="error">The error message if saving failed.</param> + public StoredFileInfo(string error) + { + this.Error = error; + } } } diff --git a/src/SMAPI.Web/Framework/Storage/UploadResult.cs b/src/SMAPI.Web/Framework/Storage/UploadResult.cs index 483c1769..92993d42 100644 --- a/src/SMAPI.Web/Framework/Storage/UploadResult.cs +++ b/src/SMAPI.Web/Framework/Storage/UploadResult.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.Storage { /// <summary>The result of an attempt to upload a file.</summary> @@ -7,25 +9,25 @@ namespace StardewModdingAPI.Web.Framework.Storage ** Accessors *********/ /// <summary>Whether the file upload succeeded.</summary> - public bool Succeeded { get; } + [MemberNotNullWhen(true, nameof(UploadResult.ID))] + [MemberNotNullWhen(false, nameof(UploadResult.UploadError))] + public bool Succeeded => this.ID != null && this.UploadError == null; /// <summary>The file ID, if applicable.</summary> - public string ID { get; } + public string? ID { get; } /// <summary>The upload error, if any.</summary> - public string UploadError { get; } + public string? UploadError { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="succeeded">Whether the file upload succeeded.</param> /// <param name="id">The file ID, if applicable.</param> /// <param name="uploadError">The upload error, if any.</param> - public UploadResult(bool succeeded, string id, string uploadError) + public UploadResult(string? id, string? uploadError) { - this.Succeeded = succeeded; this.ID = id; this.UploadError = uploadError; } diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index f0c57c41..1b1abd81 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Web.Framework /// <param name="values">A dictionary that contains the parameters for the URL.</param> /// <param name="routeDirection">An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated.</param> /// <returns><c>true</c> if the URL parameter contains a valid value; otherwise, <c>false</c>.</returns> - public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { if (routeKey == null) throw new ArgumentNullException(nameof(routeKey)); @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework throw new ArgumentNullException(nameof(values)); return - values.TryGetValue(routeKey, out object routeValue) + values.TryGetValue(routeKey, out object? routeValue) && routeValue is string routeStr && SemanticVersion.TryParse(routeStr, allowNonStandard: true, out _); } |