summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
blob: 0e68e2c2a533c0a09d4df2105f5270c8ff73d8ba (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#nullable disable

using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit.Framework.UpdateData;

namespace StardewModdingAPI.Web.Framework.Clients.GitHub
{
    /// <summary>An HTTP client for fetching metadata from GitHub.</summary>
    internal class GitHubClient : IGitHubClient
    {
        /*********
        ** Fields
        *********/
        /// <summary>The underlying HTTP client.</summary>
        private readonly IClient Client;


        /*********
        ** Accessors
        *********/
        /// <summary>The unique key for the mod site.</summary>
        public ModSiteKey SiteKey => ModSiteKey.GitHub;


        /*********
        ** Public methods
        *********/
        /// <summary>Construct an instance.</summary>
        /// <param name="baseUrl">The base URL for the GitHub API.</param>
        /// <param name="userAgent">The user agent for the API client.</param>
        /// <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)
        {
            this.Client = new FluentClient(baseUrl)
                .SetUserAgent(userAgent)
                .AddDefault(req => req.WithHeader("Accept", acceptHeader));
            if (!string.IsNullOrWhiteSpace(username))
                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)
        {
            this.AssertKeyFormat(repo);
            try
            {
                return await this.Client
                    .GetAsync($"repos/{repo}")
                    .As<GitRepo>();
            }
            catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
            {
                return null;
            }
        }

        /// <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>
        public async Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false)
        {
            this.AssertKeyFormat(repo);
            try
            {
                if (includePrerelease)
                {
                    GitRelease[] results = await this.Client
                        .GetAsync($"repos/{repo}/releases?per_page=2") // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials)
                        .AsArray<GitRelease>();
                    return results.FirstOrDefault(p => !p.IsDraft);
                }

                return await this.Client
                    .GetAsync($"repos/{repo}/releases/latest")
                    .As<GitRelease>();
            }
            catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
            {
                return null;
            }
        }

        /// <summary>Get update check info about a mod.</summary>
        /// <param name="id">The mod ID.</param>
        public async Task<IModPage> GetModData(string id)
        {
            IModPage page = new GenericModPage(this.SiteKey, id);

            if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase))
                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);
            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;
            {
                // get latest release (whether preview or stable)
                latest = await this.GetLatestReleaseAsync(id, includePrerelease: true);
                if (latest == null)
                    return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID.");

                // get stable version if different
                preview = null;
                if (latest.IsPrerelease)
                {
                    GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false);
                    if (release != null)
                    {
                        preview = latest;
                        latest = release;
                    }
                }
            }

            // get downloads
            IModDownload[] downloads = new[] { latest, preview }
                .Where(release => release != null)
                .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag))
                .ToArray();

            // return info
            return page.SetInfo(name: name, url: url, version: null, downloads: downloads);
        }

        /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
        public void Dispose()
        {
            this.Client?.Dispose();
        }


        /*********
        ** Private methods
        *********/
        /// <summary>Assert that a repository key is formatted correctly.</summary>
        /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param>
        /// <exception cref="ArgumentException">The repository key is invalid.</exception>
        private void AssertKeyFormat(string repo)
        {
            if (repo == null || !repo.Contains("/") || repo.IndexOf("/", StringComparison.OrdinalIgnoreCase) != repo.LastIndexOf("/", StringComparison.OrdinalIgnoreCase))
                throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo));
        }
    }
}