Skip to content

Commit 4851896

Browse files
committed
feat(client-cli): implement download of Cardano node distribution to retrieve the snapshot-converter binary
1 parent ea657fb commit 4851896

File tree

9 files changed

+597
-4
lines changed

9 files changed

+597
-4
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mithril-client-cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ indicatif = { version = "0.17.11", features = ["tokio"] }
3838
mithril-cli-helper = { path = "../internal/mithril-cli-helper" }
3939
mithril-client = { path = "../mithril-client", features = ["fs", "unstable"] }
4040
mithril-doc = { path = "../internal/mithril-doc" }
41+
reqwest = { workspace = true }
4142
serde = { workspace = true }
4243
serde_json = { workspace = true }
4344
slog = { workspace = true, features = [
@@ -52,3 +53,4 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
5253

5354
[dev-dependencies]
5455
mithril-common = { path = "../mithril-common", features = ["test_tools"] }
56+
mockall = { workspace = true }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
mod reqwest_github_api_client;
2+
3+
pub use reqwest_github_api_client::*;
4+
5+
use crate::commands::tools::github_release::GitHubRelease;
6+
use async_trait::async_trait;
7+
use mithril_client::MithrilResult;
8+
9+
/// Trait for interacting with the GitHub API to retrieve Cardano node release.
10+
#[cfg_attr(test, mockall::automock)]
11+
#[async_trait]
12+
pub trait GitHubApiClient {
13+
/// Retrieves a release by its tag.
14+
async fn get_release_by_tag(
15+
&self,
16+
owner: &str,
17+
repo: &str,
18+
tag: &str,
19+
) -> MithrilResult<GitHubRelease>;
20+
21+
/// Retrieves the latest release.
22+
async fn get_latest_release(&self, owner: &str, repo: &str) -> MithrilResult<GitHubRelease>;
23+
24+
/// Retrieves the prerelease.
25+
async fn get_prerelease(&self, owner: &str, repo: &str) -> MithrilResult<GitHubRelease>;
26+
27+
/// Retrieves all available releases.
28+
async fn get_all_releases(&self, owner: &str, repo: &str) -> MithrilResult<Vec<GitHubRelease>>;
29+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use anyhow::{anyhow, Context};
2+
use async_trait::async_trait;
3+
use reqwest::{Client, Url};
4+
5+
use mithril_client::MithrilResult;
6+
7+
use crate::commands::tools::github_release::GitHubRelease;
8+
9+
use super::GitHubApiClient;
10+
11+
pub struct ReqwestGitHubApiClient {
12+
client: Client,
13+
}
14+
15+
impl ReqwestGitHubApiClient {
16+
pub fn new() -> MithrilResult<Self> {
17+
let client = Client::builder()
18+
.user_agent("mithril-client")
19+
.build()
20+
.context("Failed to build Reqwest GitHub API client")?;
21+
22+
Ok(Self { client })
23+
}
24+
}
25+
26+
#[async_trait]
27+
impl GitHubApiClient for ReqwestGitHubApiClient {
28+
async fn get_release_by_tag(
29+
&self,
30+
organization: &str,
31+
repository: &str,
32+
tag: &str,
33+
) -> MithrilResult<GitHubRelease> {
34+
let url =
35+
format!("https://api.github.com/repos/{organization}/{repository}/releases/tags/{tag}");
36+
let url = Url::parse(&url)
37+
.with_context(|| format!("Failed to parse URL for GitHub API: {}", url))?;
38+
39+
let response = self
40+
.client
41+
.get(url.clone())
42+
.send()
43+
.await
44+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
45+
46+
let response = response.text().await?;
47+
let release: GitHubRelease = serde_json::from_str(&response)
48+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", response))?;
49+
50+
Ok(release)
51+
}
52+
53+
async fn get_latest_release(
54+
&self,
55+
organization: &str,
56+
repository: &str,
57+
) -> MithrilResult<GitHubRelease> {
58+
let url =
59+
format!("https://api.github.com/repos/{organization}/{repository}/releases/latest");
60+
let url = Url::parse(&url)
61+
.with_context(|| format!("Failed to parse URL for GitHub API: {}", url))?;
62+
63+
let response = self
64+
.client
65+
.get(url.clone())
66+
.send()
67+
.await
68+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
69+
70+
let response = response.text().await?;
71+
let release: GitHubRelease = serde_json::from_str(&response)
72+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", response))?;
73+
74+
Ok(release)
75+
}
76+
77+
async fn get_prerelease(
78+
&self,
79+
organization: &str,
80+
repository: &str,
81+
) -> MithrilResult<GitHubRelease> {
82+
let releases = self.get_all_releases(organization, repository).await?;
83+
84+
let prerelease = releases
85+
.into_iter()
86+
.find(|release| release.is_prerelease())
87+
.ok_or_else(|| anyhow!("No prerelease found"))?;
88+
89+
Ok(prerelease)
90+
}
91+
92+
async fn get_all_releases(
93+
&self,
94+
organization: &str,
95+
repository: &str,
96+
) -> MithrilResult<Vec<GitHubRelease>> {
97+
let url = format!("https://api.github.com/repos/{organization}/{repository}/releases");
98+
let url = Url::parse(&url)
99+
.with_context(|| format!("Failed to parse URL for GitHub API: {}", url))?;
100+
101+
let response = self
102+
.client
103+
.get(url.clone())
104+
.send()
105+
.await
106+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
107+
108+
let response = response.text().await?;
109+
let releases: Vec<GitHubRelease> = serde_json::from_str(&response)
110+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", response))?;
111+
112+
Ok(releases)
113+
}
114+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
use anyhow::anyhow;
2+
use serde::Deserialize;
3+
4+
use mithril_client::MithrilResult;
5+
6+
pub const ASSET_PLATFORM_LINUX: &str = "linux";
7+
pub const ASSET_PLATFORM_MACOS: &str = "macos";
8+
pub const ASSET_PLATFORM_WINDOWS: &str = "win64";
9+
10+
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
11+
pub struct GitHubAsset {
12+
pub name: String,
13+
pub browser_download_url: String,
14+
}
15+
16+
#[derive(Debug, Default, Clone, Deserialize)]
17+
pub struct GitHubRelease {
18+
pub assets: Vec<GitHubAsset>,
19+
pub prerelease: bool,
20+
}
21+
22+
impl GitHubRelease {
23+
pub fn is_prerelease(&self) -> bool {
24+
self.prerelease
25+
}
26+
27+
pub fn get_asset_for_os(&self, target_os: &str) -> MithrilResult<Option<&GitHubAsset>> {
28+
let os_in_asset_name = match target_os {
29+
"linux" => ASSET_PLATFORM_LINUX,
30+
"macos" => ASSET_PLATFORM_MACOS,
31+
"windows" => ASSET_PLATFORM_WINDOWS,
32+
_ => return Err(anyhow!("Unsupported platform: {}", target_os)),
33+
};
34+
35+
let asset = self
36+
.assets
37+
.iter()
38+
.find(|asset| asset.name.contains(os_in_asset_name));
39+
40+
Ok(asset)
41+
}
42+
43+
#[cfg(test)]
44+
pub fn dummy_with_all_supported_assets() -> Self {
45+
GitHubRelease {
46+
assets: vec![
47+
GitHubAsset {
48+
name: format!("asset-name-{}.tar.gz", ASSET_PLATFORM_LINUX),
49+
browser_download_url: "https://release-assets.com/linux".to_string(),
50+
},
51+
GitHubAsset {
52+
name: format!("asset-name-{}.tar.gz", ASSET_PLATFORM_MACOS),
53+
browser_download_url: "https://release-assets.com/macos".to_string(),
54+
},
55+
GitHubAsset {
56+
name: format!("asset-name-{}.zip", ASSET_PLATFORM_WINDOWS),
57+
browser_download_url: "https://release-assets.com/windows".to_string(),
58+
},
59+
],
60+
..GitHubRelease::default()
61+
}
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
68+
use super::*;
69+
70+
fn dummy_linux_asset() -> GitHubAsset {
71+
GitHubAsset {
72+
name: format!("asset-name-{}.tar.gz", ASSET_PLATFORM_LINUX),
73+
browser_download_url: "https://release-assets.com/linux".to_string(),
74+
}
75+
}
76+
77+
fn dummy_macos_asset() -> GitHubAsset {
78+
GitHubAsset {
79+
name: format!("asset-name-{}.tar.gz", ASSET_PLATFORM_MACOS),
80+
browser_download_url: "https://release-assets.com/macos".to_string(),
81+
}
82+
}
83+
84+
fn dummy_windows_asset() -> GitHubAsset {
85+
GitHubAsset {
86+
name: format!("asset-name-{}.zip", ASSET_PLATFORM_WINDOWS),
87+
browser_download_url: "https://release-assets.com/windows".to_string(),
88+
}
89+
}
90+
91+
#[test]
92+
fn returns_expected_asset_for_each_supported_platform() {
93+
let release = GitHubRelease {
94+
assets: vec![
95+
dummy_linux_asset(),
96+
dummy_macos_asset(),
97+
dummy_windows_asset(),
98+
],
99+
..GitHubRelease::default()
100+
};
101+
102+
{
103+
let asset = release.get_asset_for_os("linux").unwrap();
104+
assert_eq!(asset, Some(&dummy_linux_asset()));
105+
}
106+
107+
{
108+
let asset = release.get_asset_for_os("macos").unwrap();
109+
assert_eq!(asset, Some(&dummy_macos_asset()));
110+
}
111+
112+
{
113+
let asset = release.get_asset_for_os("windows").unwrap();
114+
assert_eq!(asset, Some(&dummy_windows_asset()));
115+
}
116+
}
117+
118+
#[test]
119+
fn returns_none_when_asset_is_missing() {
120+
let release = GitHubRelease {
121+
assets: vec![dummy_linux_asset()],
122+
..GitHubRelease::default()
123+
};
124+
125+
let asset = release.get_asset_for_os("macos").unwrap();
126+
127+
assert!(asset.is_none());
128+
}
129+
130+
#[test]
131+
fn fails_for_unsupported_platform() {
132+
let release = GitHubRelease {
133+
assets: vec![dummy_linux_asset()],
134+
..GitHubRelease::default()
135+
};
136+
137+
release
138+
.get_asset_for_os("unsupported")
139+
.expect_err("Should have failed for unsupported platform");
140+
}
141+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
mod reqwest_http_downloader;
2+
3+
pub use reqwest_http_downloader::*;
4+
5+
use async_trait::async_trait;
6+
use mithril_client::MithrilResult;
7+
use reqwest::Url;
8+
use std::path::{Path, PathBuf};
9+
10+
/// Trait for downloading a file over HTTP from a URL,
11+
/// saving it to a target directory with the given filename.
12+
///
13+
/// Returns the path to the downloaded file.
14+
#[cfg_attr(test, mockall::automock)]
15+
#[async_trait]
16+
pub trait HttpDownloader {
17+
async fn download(
18+
&self,
19+
url: Url,
20+
download_dir: &Path,
21+
filename: &str,
22+
) -> MithrilResult<PathBuf>;
23+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use std::{
2+
fs::File,
3+
io::Write,
4+
path::{Path, PathBuf},
5+
};
6+
7+
use anyhow::Context;
8+
use async_trait::async_trait;
9+
use reqwest::{Client, Url};
10+
11+
use mithril_client::MithrilResult;
12+
13+
use super::HttpDownloader;
14+
15+
/// [ReqwestHttpDownloader] is an implementation of the [HttpDownloader].
16+
pub struct ReqwestHttpDownloader {
17+
client: Client,
18+
}
19+
20+
impl ReqwestHttpDownloader {
21+
/// Creates a new instance of [ReqwestHttpDownloader].
22+
pub fn new() -> MithrilResult<Self> {
23+
let client = Client::builder()
24+
.build()
25+
.with_context(|| "Failed to build Reqwest HTTP client")?;
26+
27+
Ok(Self { client })
28+
}
29+
}
30+
31+
#[async_trait]
32+
impl HttpDownloader for ReqwestHttpDownloader {
33+
async fn download(
34+
&self,
35+
url: Url,
36+
download_dir: &Path,
37+
filename: &str,
38+
) -> MithrilResult<PathBuf> {
39+
let response = self
40+
.client
41+
.get(url.clone())
42+
.send()
43+
.await
44+
.with_context(|| format!("Failed to download file from URL: {}", url))?;
45+
46+
let bytes = response.bytes().await?;
47+
let download_filepath = download_dir.join(filename);
48+
let mut file = File::create(&download_filepath)?;
49+
file.write_all(&bytes)?;
50+
51+
Ok(download_filepath)
52+
}
53+
}

0 commit comments

Comments
 (0)