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
42 let request = github_api_request(&url)?;
43
44 let mut response = http
45 .send(request)
46 .await
47 .context("error fetching latest release")?;
48
49 let mut body = Vec::new();
50 response
51 .body_mut()
52 .read_to_end(&mut body)
53 .await
54 .context("error reading latest release")?;
55
56 if response.status().is_client_error() {
57 let text = String::from_utf8_lossy(body.as_slice());
58 bail!(
59 "status error {}, response: {text:?}",
60 response.status().as_u16()
61 );
62 }
63
64 let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
65 Ok(releases) => releases,
66
67 Err(err) => {
68 log::error!("Error deserializing: {err:?}");
69 log::error!(
70 "GitHub API response text: {:?}",
71 String::from_utf8_lossy(body.as_slice())
72 );
73 anyhow::bail!("error deserializing latest release: {err:?}");
74 }
75 };
76
77 let mut release = releases
78 .into_iter()
79 .filter(|release| !require_assets || !release.assets.is_empty())
80 .find(|release| release.pre_release == pre_release)
81 .context("finding a prerelease")?;
82 release.assets.iter_mut().for_each(|asset| {
83 if let Some(digest) = &mut asset.digest
84 && let Some(stripped) = digest.strip_prefix("sha256:")
85 {
86 *digest = stripped.to_owned();
87 }
88 });
89 Ok(release)
90}
91
92pub async fn get_release_by_tag_name(
93 repo_name_with_owner: &str,
94 tag: &str,
95 http: Arc<dyn HttpClient>,
96) -> anyhow::Result<GithubRelease> {
97 let url = format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}");
98
99 let request = github_api_request(&url)?;
100
101 let mut response = http
102 .send(request)
103 .await
104 .context("error fetching latest release")?;
105
106 let mut body = Vec::new();
107 let status = response.status();
108 response
109 .body_mut()
110 .read_to_end(&mut body)
111 .await
112 .context("error reading latest release")?;
113
114 if status.is_client_error() {
115 let text = String::from_utf8_lossy(body.as_slice());
116 bail!(
117 "status error {}, response: {text:?}",
118 response.status().as_u16()
119 );
120 }
121
122 let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
123 log::error!("Error deserializing: {err:?}");
124 log::error!(
125 "GitHub API response text: {:?}",
126 String::from_utf8_lossy(body.as_slice())
127 );
128 anyhow!("error deserializing GitHub release: {err:?}")
129 })?;
130
131 Ok(release)
132}
133
134fn github_api_request(url: &str) -> Result<Request<AsyncBody>> {
135 Request::get(url)
136 .follow_redirects(crate::RedirectPolicy::FollowAll)
137 .timeout(GITHUB_RELEASE_REQUEST_TIMEOUT)
138 .body(Default::default())
139 .map_err(Into::into)
140}
141
142#[derive(Debug, PartialEq, Eq, Clone, Copy)]
143pub enum AssetKind {
144 TarGz,
145 Gz,
146 Zip,
147}
148
149pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -> Result<String> {
150 let mut url = Url::parse(&format!(
151 "https://github.com/{repo_name_with_owner}/archive/refs/tags",
152 ))?;
153 let asset_filename = format!(
156 "{tag}.{extension}",
157 extension = match kind {
158 AssetKind::TarGz => "tar.gz",
159 AssetKind::Gz => "gz",
160 AssetKind::Zip => "zip",
161 }
162 );
163 url.path_segments_mut()
164 .map_err(|()| anyhow!("cannot modify url path segments"))?
165 .push(&asset_filename);
166 Ok(url.to_string())
167}
168
169#[cfg(test)]
170mod tests {
171 use crate::{
172 RequestTimeout,
173 github::{AssetKind, GITHUB_RELEASE_REQUEST_TIMEOUT, build_asset_url, github_api_request},
174 };
175
176 #[test]
177 fn github_api_requests_have_a_total_deadline() {
178 let request =
179 github_api_request("https://api.github.com/repos/zed-industries/zed/releases").unwrap();
180
181 assert_eq!(
182 request.extensions().get::<RequestTimeout>(),
183 Some(&RequestTimeout(GITHUB_RELEASE_REQUEST_TIMEOUT))
184 );
185 }
186
187 #[test]
188 fn test_build_asset_url() {
189 let tag = "release/2.3.5";
190 let repo_name_with_owner = "microsoft/vscode-eslint";
191
192 let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap();
193 assert_eq!(
194 tarball,
195 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
196 );
197
198 let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap();
199 assert_eq!(
200 zip,
201 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip"
202 );
203 }
204}