Skip to main content

crates_readme_table/
api.rs

1//! Crates.io API client and data types.
2
3use reqwest::Client;
4use serde::Deserialize;
5use urlogger::{LogLevel, log};
6
7/// User-Agent for crates.io API (required by crates.io crawler policy).
8pub const CRATES_IO_USER_AGENT: &str =
9    "crates-readme-table (https://github.com/un-rust/template; help@crates.io for issues)";
10
11const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
12
13#[derive(Debug, Deserialize)]
14pub struct Crate {
15    pub id: String,
16    pub name: String,
17    pub description: Option<String>,
18    pub homepage: Option<String>,
19    pub repository: Option<String>,
20    #[serde(default)]
21    pub links: Option<CrateLinks>,
22}
23
24#[derive(Debug, Deserialize)]
25pub struct CrateLinks {
26    pub version_downloads: Option<String>,
27    pub versions: Option<String>,
28    pub owners: Option<String>,
29    pub owner_team: Option<String>,
30    pub owner_user: Option<String>,
31    pub reverse_dependencies: Option<String>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct Meta {
36    pub total: u64,
37    pub next_page: Option<u32>,
38    pub prev_page: Option<u32>,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct CratesResponse {
43    pub crates: Vec<Crate>,
44    pub meta: Meta,
45}
46
47/// Fetches one page of crates for a user from crates.io API.
48pub async fn get_crates(
49    client: &Client,
50    page: u32,
51    per_page: u32,
52    sort: &str,
53    user_id: u32,
54) -> Result<CratesResponse, Box<dyn std::error::Error + Send + Sync>> {
55    let url = format!(
56        "{}?page={}&per_page={}&sort={}&user_id={}",
57        CRATES_IO_API, page, per_page, sort, user_id
58    );
59    log!(LogLevel::Debug, "Fetching page {} from crates.io", page);
60    let resp = client.get(&url).send().await?;
61    let text = resp.text().await?;
62
63    let value: serde_json::Value =
64        serde_json::from_str(&text).map_err(|e| format!("response not JSON: {}", e))?;
65
66    // API returns {"errors": [{"detail": "..."}]} on failure (sometimes with 200)
67    if let Some(errors) = value.get("errors").and_then(|e| e.as_array()) {
68        if !errors.is_empty() {
69            let msg: String = errors
70                .iter()
71                .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
72                .collect::<Vec<_>>()
73                .join("; ");
74            return Err(if msg.is_empty() {
75                "API returned errors".into()
76            } else {
77                msg.into()
78            });
79        }
80    }
81
82    // Accept either "crates" (list endpoint) or "crate" (single-crate style) for the array
83    let crates_value = value
84        .get("crates")
85        .or_else(|| value.get("crate"))
86        .ok_or_else(|| {
87            let keys: Vec<&str> = value
88                .as_object()
89                .map(|o| o.keys().map(String::as_str).collect())
90                .unwrap_or_default();
91            format!(
92                "API response has no 'crates' or 'crate' field. Top-level keys: {:?}",
93                keys
94            )
95        })?;
96
97    let crates: Vec<Crate> = serde_json::from_value(crates_value.clone())
98        .map_err(|e| format!("failed to parse crates array: {}", e))?;
99
100    let meta = value
101        .get("meta")
102        .ok_or_else(|| "API response has no 'meta' field".to_string())?;
103    let meta: Meta = serde_json::from_value(meta.clone()).map_err(|e| format!("meta: {}", e))?;
104
105    log!(
106        LogLevel::Debug,
107        "Fetched {} crates on page {}",
108        crates.len(),
109        page
110    );
111    Ok(CratesResponse { crates, meta })
112}
113
114/// Fetches all crates for a user by paginating through the API.
115pub async fn get_all_crates(
116    client: &Client,
117    user_id: u32,
118) -> Result<Vec<Crate>, Box<dyn std::error::Error + Send + Sync>> {
119    log!(LogLevel::Info, "Fetching crates for user_id={}", user_id);
120
121    let mut crate_list = Vec::new();
122    let mut page = 1u32;
123
124    loop {
125        let data = get_crates(client, page, 10, "alpha", user_id).await?;
126        let crates = data.crates;
127        let meta = data.meta;
128        crate_list.extend(crates);
129
130        log!(
131            LogLevel::Info,
132            "Page {} done, total so far: {} (meta.total={})",
133            page,
134            crate_list.len(),
135            meta.total
136        );
137
138        match meta.next_page {
139            Some(next) => page = next,
140            None => break,
141        }
142    }
143
144    log!(
145        LogLevel::Info,
146        "Fetched {} crates for user {}",
147        crate_list.len(),
148        user_id
149    );
150
151    Ok(crate_list)
152}