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