Skip to main content

fc_gpui_http_client/
github.rs

1use crate::{AsyncBody, HttpClient, HttpRequestExt};
2use anyhow::{Context as _, Result, anyhow, bail};
3use futures::AsyncReadExt;
4use http::Request;
5use serde::Deserialize;
6use std::{sync::Arc, time::Duration};
7use url::Url;
8
9const GITHUB_RELEASE_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
10
11pub struct GitHubLspBinaryVersion {
12    pub name: String,
13    pub url: String,
14    pub digest: Option<String>,
15}
16
17#[derive(Deserialize, Debug)]
18pub struct GithubRelease {
19    pub tag_name: String,
20    #[serde(rename = "prerelease")]
21    pub pre_release: bool,
22    pub assets: Vec<GithubReleaseAsset>,
23    pub tarball_url: String,
24    pub zipball_url: String,
25}
26
27#[derive(Deserialize, Debug)]
28pub struct GithubReleaseAsset {
29    pub name: String,
30    pub browser_download_url: String,
31    pub digest: Option<String>,
32}
33
34pub async fn latest_github_release(
35    repo_name_with_owner: &str,
36    require_assets: bool,
37    pre_release: bool,
38    http: Arc<dyn HttpClient>,
39) -> anyhow::Result<GithubRelease> {
40    let url = format!("https://api.github.com/repos/{repo_name_with_owner}/releases");
41    let body = fetch_github_api(&http, url).await?;
42
43    let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
44        Ok(releases) => releases,
45
46        Err(err) => {
47            log::error!("Error deserializing: {err:?}");
48            log::error!(
49                "GitHub API response text: {:?}",
50                String::from_utf8_lossy(body.as_slice())
51            );
52            anyhow::bail!("error deserializing latest release: {err:?}");
53        }
54    };
55
56    let mut release = releases
57        .into_iter()
58        .filter(|release| !require_assets || !release.assets.is_empty())
59        .find(|release| release.pre_release == pre_release)
60        .context(if pre_release {
61            "finding a prerelease"
62        } else {
63            "finding a stable release"
64        })?;
65    release.assets.iter_mut().for_each(|asset| {
66        if let Some(digest) = &mut asset.digest
67            && let Some(stripped) = digest.strip_prefix("sha256:")
68        {
69            *digest = stripped.to_owned();
70        }
71    });
72    Ok(release)
73}
74
75pub async fn get_release_by_tag_name(
76    repo_name_with_owner: &str,
77    tag: &str,
78    http: Arc<dyn HttpClient>,
79) -> anyhow::Result<GithubRelease> {
80    let url = format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}");
81    let body = fetch_github_api(&http, url).await?;
82
83    let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
84        log::error!("Error deserializing: {err:?}");
85        log::error!(
86            "GitHub API response text: {:?}",
87            String::from_utf8_lossy(body.as_slice())
88        );
89        anyhow!("error deserializing GitHub release: {err:?}")
90    })?;
91
92    Ok(release)
93}
94
95/// Sends a GET request to the GitHub API and returns the raw response body,
96/// failing on client-error statuses.
97async fn fetch_github_api(http: &Arc<dyn HttpClient>, url: String) -> Result<Vec<u8>> {
98    let request = github_api_request(&url)?;
99
100    let mut response = http
101        .send(request)
102        .await
103        .with_context(|| format!("error fetching {url}"))?;
104
105    let mut body = Vec::new();
106    response
107        .body_mut()
108        .read_to_end(&mut body)
109        .await
110        .with_context(|| format!("error reading response from {url}"))?;
111
112    if response.status().is_client_error() {
113        let text = String::from_utf8_lossy(body.as_slice());
114        bail!(
115            "status error {}, response: {text:?}",
116            response.status().as_u16()
117        );
118    }
119
120    Ok(body)
121}
122
123fn github_api_request(url: &str) -> Result<Request<AsyncBody>> {
124    Request::get(url)
125        .follow_redirects(crate::RedirectPolicy::FollowAll)
126        .timeout(GITHUB_RELEASE_REQUEST_TIMEOUT)
127        .body(Default::default())
128        .map_err(Into::into)
129}
130
131#[derive(Debug, PartialEq, Eq, Clone, Copy)]
132pub enum AssetKind {
133    TarGz,
134    Gz,
135    Zip,
136}
137
138pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -> Result<String> {
139    let mut url = Url::parse(&format!(
140        "https://github.com/{repo_name_with_owner}/archive/refs/tags",
141    ))?;
142    // We're pushing this here, because tags may contain `/` and other characters
143    // that need to be escaped.
144    let asset_filename = format!(
145        "{tag}.{extension}",
146        extension = match kind {
147            AssetKind::TarGz => "tar.gz",
148            AssetKind::Gz => "gz",
149            AssetKind::Zip => "zip",
150        }
151    );
152    url.path_segments_mut()
153        .map_err(|()| anyhow!("cannot modify url path segments"))?
154        .push(&asset_filename);
155    Ok(url.to_string())
156}
157
158#[cfg(test)]
159mod tests {
160    use crate::{
161        RequestTimeout,
162        github::{AssetKind, GITHUB_RELEASE_REQUEST_TIMEOUT, build_asset_url, github_api_request},
163    };
164
165    #[test]
166    fn github_api_requests_have_a_total_deadline() {
167        let request =
168            github_api_request("https://api.github.com/repos/zed-industries/zed/releases").unwrap();
169
170        assert_eq!(
171            request.extensions().get::<RequestTimeout>(),
172            Some(&RequestTimeout(GITHUB_RELEASE_REQUEST_TIMEOUT))
173        );
174    }
175
176    #[test]
177    fn test_build_asset_url() {
178        let tag = "release/2.3.5";
179        let repo_name_with_owner = "microsoft/vscode-eslint";
180
181        let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap();
182        assert_eq!(
183            tarball,
184            "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
185        );
186
187        let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap();
188        assert_eq!(
189            zip,
190            "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip"
191        );
192    }
193}