cargo_whatfeatures/
client.rs

1use crate::registry::Crate;
2use anyhow::Context as _;
3use time::format_description::FormatItem;
4
5/// An HTTP client for interacting with crates.io
6pub struct Client {
7    host: String,
8}
9
10impl Client {
11    /// Create a new HTTP client with the provided host (e.g. `https://crates.io` or `http://localhost`)
12    pub fn new(host: impl ToString) -> Self {
13        Self {
14            host: host.to_string(),
15        }
16    }
17
18    /// Lookup and cache the latest version for this crate
19    pub fn cache_latest(&self, crate_name: &str) -> anyhow::Result<Crate> {
20        let Version { version, .. } = self.get_latest(crate_name)?;
21        self.cache_crate(crate_name, &version)
22    }
23
24    /// Lookup and cache the specified version for this crate
25    pub fn cache_crate(&self, crate_name: &str, crate_version: &str) -> anyhow::Result<Crate> {
26        let (yanked, data) = self.download_crate(crate_name, crate_version)?;
27        crate::util::extract_crate(&data, crate_name, crate_version).map(|path| Crate {
28            name: crate_name.to_string(),
29            version: crate_version.to_string(),
30            path,
31            yanked: yanked.into(),
32        })
33    }
34
35    /// Get the latest version for this crate
36    pub fn get_latest(&self, crate_name: &str) -> anyhow::Result<Version> {
37        self.list_versions(crate_name)?
38            .into_iter()
39            .find(|s| !s.yanked)
40            .ok_or_else(|| anyhow::anyhow!("no available version for: {}", crate_name))
41    }
42
43    /// Get the latest version for this crate
44    pub fn get_version(&self, crate_name: &str, semver: &str) -> anyhow::Result<Version> {
45        self.list_versions(crate_name)?
46            .into_iter()
47            .find(|s| s.version == semver)
48            .ok_or_else(|| anyhow::anyhow!("no available version for: {} = {}", crate_name, semver))
49    }
50
51    /// Get all versions for this crate
52    pub fn list_versions(&self, crate_name: &str) -> anyhow::Result<Vec<Version>> {
53        #[derive(serde::Deserialize)]
54        struct Resp {
55            versions: Vec<Version>,
56        }
57
58        self.fetch_json(&format!("/api/v1/crates/{}", crate_name))
59            .map(|resp: Resp| resp.versions)
60            .with_context(|| anyhow::anyhow!("list versions for: {}", crate_name))
61    }
62}
63
64impl Client {
65    fn download_crate(
66        &self,
67        crate_name: &str,
68        crate_version: &str,
69    ) -> anyhow::Result<(bool, Vec<u8>)> {
70        #[derive(Debug, serde::Deserialize)]
71        struct Resp {
72            version: Version,
73        }
74
75        let version = self
76            .fetch_json(&format!("/api/v1/crates/{}/{}", crate_name, crate_version))
77            .map(|resp: Resp| resp.version)
78            .with_context(|| anyhow::anyhow!("download crate {}/{}", crate_name, crate_version))?;
79
80        anyhow::ensure!(version.name == crate_name, "received the wrong crate");
81        anyhow::ensure!(
82            version.version == crate_version,
83            "received the wrong version"
84        );
85        anyhow::ensure!(!version.dl_path.is_empty(), "no download path available");
86
87        self.fetch_bytes(&version.dl_path)
88            .map(|data| (version.yanked, data))
89    }
90
91    fn fetch_json<T>(&self, endpoint: &str) -> anyhow::Result<T>
92    where
93        for<'de> T: serde::Deserialize<'de>,
94    {
95        let resp = attohttpc::get(format!("{}{}", self.host, endpoint))
96            .header("USER-AGENT", Self::get_user_agent())
97            .send()?;
98
99        anyhow::ensure!(
100            resp.status().is_success(),
101            "cannot fetch json for {}",
102            endpoint
103        );
104
105        resp.json()
106            .with_context(move || format!("cannot parse json from {}", endpoint))
107    }
108
109    fn fetch_bytes(&self, endpoint: &str) -> anyhow::Result<Vec<u8>> {
110        let resp = attohttpc::get(format!("{}{}", self.host, endpoint))
111            .header("USER-AGENT", Self::get_user_agent())
112            .send()?;
113
114        anyhow::ensure!(
115            resp.status().is_success(),
116            "cannot fetch bytes for {}",
117            endpoint
118        );
119
120        let len = resp
121            .headers()
122            .get("Content-Length")
123            .and_then(|s| s.to_str().ok()?.parse::<usize>().ok())
124            .with_context(|| "cannot get Content-Length")?;
125
126        let bytes = resp.bytes()?;
127        anyhow::ensure!(len == bytes.len(), "fetch size was wrong");
128
129        Ok(bytes)
130    }
131
132    const fn get_user_agent() -> &'static str {
133        concat!(
134            env!("CARGO_PKG_NAME"),
135            "/",
136            env!("CARGO_PKG_VERSION"),
137            " (",
138            env!("CARGO_PKG_REPOSITORY"),
139            ")"
140        )
141    }
142}
143
144/// A crate version
145#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
146pub struct Version {
147    #[serde(rename = "crate")]
148    /// The name of the crate
149    pub name: String,
150    #[serde(rename(deserialize = "num"))]
151    /// The semantic version of the crate
152    pub version: String,
153    /// Whether this version was yanked
154    pub yanked: bool,
155    /// The primary license of the crate
156    pub license: Option<String>,
157    /// When the crate was created
158    #[serde(with = "time::serde::rfc3339")]
159    pub created_at: time::OffsetDateTime,
160
161    dl_path: String,
162}
163
164impl Version {
165    const FMT: &'static [FormatItem<'static>] = time::macros::format_description!(
166        "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory][offset_minute]"
167    );
168
169    pub fn format_verbose_time(&self) -> String {
170        self.created_at.format(&Self::FMT).expect("valid time")
171    }
172
173    pub fn format_approx_time_span(&self) -> String {
174        let d = time::OffsetDateTime::now_utc() - self.created_at;
175        macro_rules! try_time {
176            ($($expr:tt => $class:expr)*) => {{
177                $(
178                    match d.$expr() {
179                        0 => {}
180                        1 => return format!("1 {} ago", $class),
181                        d => return format!("{} {}s ago", d, $class),
182                    }
183                )*
184                String::from("just now")
185            }};
186        }
187
188        try_time! {
189            whole_weeks   => "week"
190            whole_days    => "day"
191            whole_hours   => "hour"
192            whole_minutes => "minute"
193            whole_seconds => "second"
194        }
195    }
196}
197
198pub mod json {
199    use crate::{features::Workspace, Version};
200
201    fn format_timestamp(time: &time::OffsetDateTime) -> String {
202        time.format(&Version::FMT).expect("valid time")
203    }
204
205    pub fn create_crates_from_versions(
206        name: &str,
207        versions: impl IntoIterator<Item = Version>,
208    ) -> serde_json::Value {
209        let array = versions.into_iter().map(|version| {
210            serde_json::json!({
211                "version": version.version,
212                "yanked": version.yanked,
213                "license": version.license,
214                "created_at": format_timestamp(&version.created_at),
215                "dl_path": version.dl_path,
216            })
217        });
218
219        serde_json::json!({
220            name: array.collect::<Vec<_>>()
221        })
222    }
223
224    pub fn create_crates_from_workspace<'a>(
225        workspace: &str,
226        crates: impl IntoIterator<Item = (&'a String, &'a String, bool)>,
227    ) -> serde_json::Value {
228        let array = crates.into_iter().map(|(name, version, published)| {
229            serde_json::json!({
230                "crate": name,
231                "version": version,
232                "published": published,
233            })
234        });
235
236        serde_json::json!({
237            workspace: array.collect::<Vec<_>>()
238        })
239    }
240
241    pub fn workspace(workspace: Workspace) -> serde_json::Value {
242        let map = workspace
243            .map
244            .into_iter()
245            .map(|(_, features)| {
246                serde_json::json!({
247                    "crate": features.name,
248                    "version": features.version,
249                    "published": features.published,
250                    "features": features.features,
251                    "dependencies": {
252                        "optional": features.optional_deps,
253                        "required": features.required_deps,
254                    }
255                })
256            })
257            .collect::<Vec<_>>();
258
259        serde_json::json!({
260            "workspace": workspace.hint,
261            "members": map
262        })
263    }
264}