hub_tool/
lib.rs

1use anyhow::Context;
2use chrono::{DateTime, Utc};
3use futures::future::join_all;
4use reqwest::{header, Client};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use url::Url;
8
9/// Struct that holds the client and the URL to send request to the Docker Hub
10pub struct DockerHubClient {
11    /// Contains the instace for the reqwest Client with the required headers and
12    /// configuration if any.
13    pub client: Client,
14
15    // TODO(alvarobartt): unless custom Docker Registries are supported, the URL may not be
16    // required
17    /// Holds the URL for the Docker Hub (https://hub.docker.com)
18    pub url: Url,
19}
20
21#[derive(Serialize, Deserialize, Debug)]
22pub struct ApiResult<T> {
23    count: usize,
24    next: Option<String>,
25    previous: Option<String>,
26    results: Vec<T>,
27}
28
29#[derive(Serialize, Deserialize, Debug)]
30pub struct Image {
31    architecture: String,
32    features: String,
33    variant: Option<String>,
34    digest: String,
35    os: Option<String>,
36    os_features: String,
37    os_version: Option<String>,
38    size: u64,
39    status: String,
40    last_pulled: DateTime<Utc>,
41    last_pushed: DateTime<Utc>,
42}
43
44#[derive(Serialize, Deserialize, Debug)]
45pub struct Tag {
46    /// The Docker ID of the creator of the current tag
47    creator: u64,
48
49    /// The ID of the current tag on the Docker Hub
50    id: u64,
51
52    images: Vec<Image>,
53    last_updated: DateTime<Utc>,
54    last_updater: u64,
55    last_updater_username: String,
56
57    /// The name of the tag for a given repository in the Docker Hub
58    name: String,
59
60    repository: u64,
61    full_size: u64,
62    v2: bool,
63    tag_status: String,
64    tag_last_pulled: DateTime<Utc>,
65    tag_last_pushed: DateTime<Utc>,
66    media_type: String,
67    content_type: String,
68    digest: String,
69}
70
71#[derive(Serialize, Deserialize, Debug)]
72pub struct Category {
73    name: String,
74    slug: String,
75}
76
77#[derive(Serialize, Deserialize, Debug)]
78pub struct Repository {
79    /// The name of the repository on the Docker Hub
80    pub name: String,
81
82    /// The namespace i.e. user or organization where the repository lives in
83    namespace: String,
84
85    /// The type of repository, can be any of "image", etc.
86    repository_type: String,
87
88    status: usize,
89
90    status_description: String,
91
92    // TODO: It cannot be None, but it can be empty which is practically the same, so let's handle
93    // this in the future to have some consistency and use None() over Some("")
94    description: String,
95
96    is_private: bool,
97
98    star_count: usize,
99
100    pull_count: usize,
101
102    last_updated: DateTime<Utc>,
103
104    last_modified: DateTime<Utc>,
105
106    date_registered: DateTime<Utc>,
107
108    // TODO: same as in `description`
109    affiliation: String,
110
111    media_types: Vec<String>,
112
113    content_types: Vec<String>,
114
115    categories: Vec<Category>,
116
117    /// The size of the virtual image in bytes
118    storage_size: u64,
119}
120
121impl DockerHubClient {
122    /// Creates a new instance of DockerHubClient with the provided authentication
123    ///
124    /// This method creates a new instance of the DockerHubClient with the provided token,
125    /// which should have read access to the Docker Hub, to be able to call the rest of the
126    /// methods within this struct. This method will configure and setup the HTTP client that
127    /// will be used within the rest of the methods to send requests to the Docker Hub.
128    pub fn new(token: &str) -> anyhow::Result<Self> {
129        let url = Url::parse("https://hub.docker.com").context("couldn't parse docker hub url")?;
130
131        let mut headers = header::HeaderMap::new();
132        headers.insert(
133            header::AUTHORIZATION,
134            header::HeaderValue::from_str(&format!("Bearer {}", token))
135                .context("couldn't add authorization header with provided token")?,
136        );
137
138        let client = Client::builder()
139            .default_headers(headers)
140            .build()
141            .context("couldn't build the reqwest client")?;
142
143        Ok(Self { client, url })
144    }
145
146    /// List all the repositories under a given org or username on the Docker Hub
147    ///
148    /// This method lists all the repositories for a given organization or user via
149    /// the `org` argument that are uploaded and publicly available on the Docker Hub.
150    /// Note that if the repository is private but the provided token has access to it,
151    /// then the repositories will be listed, otherwise only the public ones (if any)
152    /// will be listed.
153    pub async fn list_repositories(&self, org: &str) -> anyhow::Result<Vec<Repository>> {
154        let url = self
155            .url
156            .join(&format!("v2/namespaces/{}/repositories", org)) // For some reason the endpoint `v2/repositories/{}` works seamlessly
157            .context("failed formatting the url with the provided org")?;
158
159        fetch::<Repository>(&self.client, &url)
160            .await
161            .context("fetching the provided url failed")
162    }
163
164    /// List all the tags for a given repository on the Docker Hub
165    ///
166    /// This method expects both the organization or username via the `org`
167    /// argument plus the `repository` name for the repository that the tags
168    /// will be listed for.
169    pub async fn list_tags(&self, org: &str, repository: &str) -> anyhow::Result<Vec<Tag>> {
170        let url = self
171            .url
172            .join(&format!(
173                "v2/namespaces/{}/repositories/{}/tags", // For some reason the endpoint `v2/repositories/{}/{}/tags` works seamlessly
174                org, repository
175            ))
176            .context("failed formatting the url with the provided org and repository")?;
177
178        fetch::<Tag>(&self.client, &url)
179            .await
180            .context("fetching the provided url failed")
181    }
182}
183
184pub async fn fetch<T>(client: &Client, url: &Url) -> anyhow::Result<Vec<T>>
185where
186    T: for<'de> Deserialize<'de> + Send + 'static,
187{
188    let result = match client.get(url.clone()).send().await {
189        Ok(response) => match response.json::<Value>().await {
190            Ok(out) => serde_json::from_value::<ApiResult<T>>(out)
191                .context("parsing the output json into an `ApiResult<T>` struct failed")?,
192            Err(e) => anyhow::bail!("failed with error {e}"),
193        },
194        Err(e) => anyhow::bail!("failed with error {e}"),
195    };
196
197    if let Some(_) = result.next {
198        let page_size = result.results.len();
199        let pages = (result.count + page_size - 1) / page_size;
200
201        // TODO: avoid spawning a bunch of tasks
202        let mut tasks = Vec::new();
203        for page in 2..pages {
204            let new_url = url.clone();
205            let new_client = client.clone();
206            tasks.push(tokio::spawn(async move {
207                match new_client
208                    .get(new_url)
209                    .query(&[("page", page), ("page_size", page_size)])
210                    .send()
211                    .await
212                {
213                    Ok(response) => match response.json::<Value>().await {
214                        Ok(out) => serde_json::from_value::<ApiResult<T>>(out).context(
215                            "parsing the output json into an `ApiResult<T>` struct failed",
216                        ),
217                        Err(e) => anyhow::bail!("failed with error {e}"),
218                    },
219                    Err(e) => anyhow::bail!("failed with error {e}"),
220                }
221            }));
222        }
223
224        let mut results = result.results;
225
226        let futures = join_all(tasks).await;
227        for future in futures {
228            match future {
229                Ok(Ok(result)) => {
230                    results.extend(result.results);
231                }
232                Ok(Err(e)) => {
233                    anyhow::bail!("failed to fetch: {:?}", e);
234                }
235                Err(e) => {
236                    anyhow::bail!("failed capturing the task future: {:?}", e);
237                }
238            }
239        }
240        Ok(results)
241    } else {
242        Ok(result.results)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use serde_json::json;
250
251    #[test]
252    fn test_repository_serde() {
253        let value = json!({
254          "name": "ollama",
255          "namespace": "ollama",
256          "repository_type": "image",
257          "status": 1,
258          "status_description": "active",
259          "description": "The easiest way to get up and running with large language models.",
260          "is_private": false,
261          "star_count": 1183,
262          "pull_count": 13256501,
263          "last_updated": "2025-03-04T04:01:22.754331Z",
264          "last_modified": "2024-10-16T13:48:34.145251Z",
265          "date_registered": "2023-06-29T23:27:34.326426Z",
266          "affiliation": "",
267          "media_types": [
268            "application/vnd.docker.container.image.v1+json",
269            "application/vnd.docker.distribution.manifest.list.v2+json",
270            "application/vnd.oci.image.config.v1+json",
271            "application/vnd.oci.image.index.v1+json"
272          ],
273          "content_types": [
274            "image"
275          ],
276          "categories": [
277            {
278              "name": "Machine Learning & AI",
279              "slug": "machine-learning-and-ai"
280            },
281            {
282              "name": "Developer Tools",
283              "slug": "developer-tools"
284            }
285          ],
286          "storage_size": 662988133055 as u64,
287        });
288
289        let repository = serde_json::from_value::<Repository>(value)
290            .context("failed to deserialize the repository payload")
291            .unwrap();
292
293        println!("{repository:#?}");
294    }
295
296    #[test]
297    fn test_tag_serde() {
298        let value = json!({
299          "creator": 14304909,
300          "id": 529481097,
301          "images": [
302            {
303              "architecture": "amd64",
304              "features": "",
305              "variant": null,
306              "digest": "sha256:96b6a4e66250499a9d87a4adf259ced7cd213e2320fb475914217f4d69abe98d",
307              "os": "linux",
308              "os_features": "",
309              "os_version": null,
310              "size": 755930694,
311              "status": "active",
312              "last_pulled": "2025-03-05T07:52:00.613197154Z",
313              "last_pushed": "2024-01-16T20:54:52Z"
314            }
315          ],
316          "last_updated": "2024-01-16T20:54:55.914808Z",
317          "last_updater": 14304909,
318          "last_updater_username": "mxyng",
319          "name": "gguf",
320          "repository": 22180121,
321          "full_size": 755930694,
322          "v2": true,
323          "tag_status": "active",
324          "tag_last_pulled": "2025-03-05T07:52:00.613197154Z",
325          "tag_last_pushed": "2024-01-16T20:54:55.914808Z",
326          "media_type": "application/vnd.oci.image.index.v1+json",
327          "content_type": "image",
328          "digest": "sha256:7c49490a9e4a7ca4326e09c4b47bc525aa0a9dfc8ea0b3a30d62af23a60db712"
329        });
330
331        let tag = serde_json::from_value::<Tag>(value)
332            .context("failed to deserialize the tag payload")
333            .unwrap();
334
335        println!("{tag:#?}");
336    }
337
338    #[tokio::test]
339    async fn test_list_repositories() -> anyhow::Result<()> {
340        let pat =
341            std::env::var("DOCKER_PAT").context("environment variable `DOCKER_PAT` is not set")?;
342        let dh =
343            DockerHubClient::new(&pat).context("the docker hub client couldn't be instantiated")?;
344
345        println!("{:#?}", dh.list_repositories("ollama").await);
346
347        Ok(())
348    }
349
350    #[tokio::test]
351    async fn test_list_tags() -> anyhow::Result<()> {
352        let pat =
353            std::env::var("DOCKER_PAT").context("environment variable `DOCKER_PAT` is not set")?;
354        let dh =
355            DockerHubClient::new(&pat).context("the docker hub client couldn't be instantiated")?;
356
357        println!("{:#?}", dh.list_tags("ollama", "ollama").await);
358
359        Ok(())
360    }
361}