zed_http_client/
github.rs

1use crate::HttpClient;
2use anyhow::{Context as _, Result, anyhow, bail};
3use futures::AsyncReadExt;
4use serde::Deserialize;
5use std::sync::Arc;
6use url::Url;
7
8pub struct GitHubLspBinaryVersion {
9    pub name: String,
10    pub url: String,
11    pub digest: Option<String>,
12}
13
14#[derive(Deserialize, Debug)]
15pub struct GithubRelease {
16    pub tag_name: String,
17    #[serde(rename = "prerelease")]
18    pub pre_release: bool,
19    pub assets: Vec<GithubReleaseAsset>,
20    pub tarball_url: String,
21    pub zipball_url: String,
22}
23
24#[derive(Deserialize, Debug)]
25pub struct GithubReleaseAsset {
26    pub name: String,
27    pub browser_download_url: String,
28    pub digest: Option<String>,
29}
30
31pub async fn latest_github_release(
32    repo_name_with_owner: &str,
33    require_assets: bool,
34    pre_release: bool,
35    http: Arc<dyn HttpClient>,
36) -> anyhow::Result<GithubRelease> {
37    let mut response = http
38        .get(
39            format!("https://api.github.com/repos/{repo_name_with_owner}/releases").as_str(),
40            Default::default(),
41            true,
42        )
43        .await
44        .context("error fetching latest release")?;
45
46    let mut body = Vec::new();
47    response
48        .body_mut()
49        .read_to_end(&mut body)
50        .await
51        .context("error reading latest release")?;
52
53    if response.status().is_client_error() {
54        let text = String::from_utf8_lossy(body.as_slice());
55        bail!(
56            "status error {}, response: {text:?}",
57            response.status().as_u16()
58        );
59    }
60
61    let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
62        Ok(releases) => releases,
63
64        Err(err) => {
65            log::error!("Error deserializing: {err:?}");
66            log::error!(
67                "GitHub API response text: {:?}",
68                String::from_utf8_lossy(body.as_slice())
69            );
70            anyhow::bail!("error deserializing latest release: {err:?}");
71        }
72    };
73
74    let mut release = releases
75        .into_iter()
76        .filter(|release| !require_assets || !release.assets.is_empty())
77        .find(|release| release.pre_release == pre_release)
78        .context("finding a prerelease")?;
79    release.assets.iter_mut().for_each(|asset| {
80        if let Some(digest) = &mut asset.digest
81            && let Some(stripped) = digest.strip_prefix("sha256:")
82        {
83            *digest = stripped.to_owned();
84        }
85    });
86    Ok(release)
87}
88
89pub async fn get_release_by_tag_name(
90    repo_name_with_owner: &str,
91    tag: &str,
92    http: Arc<dyn HttpClient>,
93) -> anyhow::Result<GithubRelease> {
94    let mut response = http
95        .get(
96            &format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}"),
97            Default::default(),
98            true,
99        )
100        .await
101        .context("error fetching latest release")?;
102
103    let mut body = Vec::new();
104    let status = response.status();
105    response
106        .body_mut()
107        .read_to_end(&mut body)
108        .await
109        .context("error reading latest release")?;
110
111    if status.is_client_error() {
112        let text = String::from_utf8_lossy(body.as_slice());
113        bail!(
114            "status error {}, response: {text:?}",
115            response.status().as_u16()
116        );
117    }
118
119    let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
120        log::error!("Error deserializing: {err:?}");
121        log::error!(
122            "GitHub API response text: {:?}",
123            String::from_utf8_lossy(body.as_slice())
124        );
125        anyhow!("error deserializing GitHub release: {err:?}")
126    })?;
127
128    Ok(release)
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::github::{AssetKind, build_asset_url};
161
162    #[test]
163    fn test_build_asset_url() {
164        let tag = "release/2.3.5";
165        let repo_name_with_owner = "microsoft/vscode-eslint";
166
167        let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap();
168        assert_eq!(
169            tarball,
170            "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
171        );
172
173        let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap();
174        assert_eq!(
175            zip,
176            "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip"
177        );
178    }
179}