fc_gpui_http_client/
github.rs1use 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
95async 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 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}