Skip to main content

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